openapi: 3.1.0 info: title: ORI Registry API version: 1.1.0 summary: Oxton Research Identifier — registration and resolution API for journals description: | ORI is an institutional identifier of Oxton. It is not a DOI and not a Crossref registration. Read endpoints are public and return only public records. Write endpoints require a journal credential and act only on that journal; records of other journals answer 404. Any approved journal can integrate its editorial system directly — no connector or plugin is required — and issuing identifiers is free. Editorial flow (recommended): 1. GET /me — check the credential, its journal and scopes. 2. POST /registrations?validate_only=1 — run every check without writing anything. 3. POST /registrations (target_status "reserved") after acceptance — the identifier is definitive; print it in the proofs and in the PDF. Reserved records are not public and not in the repository. 4. GET /registrations/{id} or GET /records/{identifier} — follow the request / read the record. 5. PATCH /records/{identifier} (If-Match) or POST /registrations with the same source_ref — update metadata. 6. POST /registrations (target_status "published", same source_ref, published_date, article_url) — confirms the publication with the reserved identifier; the record becomes public and is indexed by the ORI Research Repository automatically (no second call). Authentication: HMAC-signed requests (recommended) or Bearer token; credentials are accepted only over HTTPS. Signature: hex HMAC-SHA256, keyed with the credential secret (the part after "ori__"), of six lines joined by "\n": METHOD, route (path after /wp-json, e.g. /ori/v1/registrations), query parameters sorted by name and encoded with RFC 3986 (empty when none), X-ORI-Timestamp, X-ORI-Nonce, hex SHA-256 of the exact body ("" when none). The timestamp must be within 300 seconds of the server clock and each nonce is accepted once. Limits: 300 requests per minute per credential (429 with Retry-After) and 120 per minute per client address on the ori/v1 namespace. Every response carries X-Request-Id. Rotation: in the dashboard (Credentials › Rotate) a new secret is issued at once; the previous one keeps working for 7 days, 1 day or stops immediately, as chosen. Changes in 1.1.0 (backward compatible): signed requests, validate_only, records:status scope, HTTPS required for credentials, per-credential limit, X-Request-Id, GET /records/{identifier} with a credential returns the journal's view (reserved and draft records included). servers: - url: https://oriregistry.org/wp-json/ori/v1 security: [] tags: - name: Credential - name: Registrations description: Recommended for editorial systems (idempotent, approval queue aware). - name: Records - name: Files - name: Journals - name: Resolution paths: /me: get: tags: [Credential] summary: Who is calling (journal, scopes) security: [{ signature: [] }, { bearer: [] }] responses: '200': description: Credential information headers: { X-Request-Id: { $ref: '#/components/headers/RequestId' } } content: application/json: schema: type: object properties: type: { type: string, enum: [credential, user] } journal: type: [object, 'null'] properties: slug: { type: string } title: { type: string } issn_electronic: { type: [string, 'null'] } requires_approval: { type: boolean } scopes: { type: array, items: { $ref: '#/components/schemas/Scope' } } entitlement: { type: [object, 'null'], description: 'Contract situation reported by ORI Billing (issuance is free).' } example: { type: credential, journal: { slug: example-journal, title: Example Journal, issn_electronic: 0000-0000, requires_approval: false }, scopes: [registrations:write, registrations:read, records:read], entitlement: null } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Error' } '429': { $ref: '#/components/responses/RateLimited' } /registrations: post: tags: [Registrations] summary: Reserve, publish or update an article (or validate only) description: | Creates the record when record.source_ref is new for the journal, otherwise updates that record (the identifier never changes). With target_status "reserved" the identifier is issued without publishing; with "published" the record becomes public. When the journal requires approval, new records wait (202 pending); updates of reserved or published records are applied at once. With ?validate_only=1 nothing is written and the answer is a ValidationResult. security: [{ signature: [registrations:write] }, { bearer: [registrations:write] }] parameters: - { name: validate_only, in: query, required: false, schema: { type: boolean }, description: 'Run every check and write nothing.' } - $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/RequestIdIn' requestBody: required: true content: application/json: schema: type: object required: [record, target_status] properties: target_status: { type: string, enum: [reserved, published] } record: { $ref: '#/components/schemas/RecordInput' } example: target_status: reserved record: source_ref: submission-123 publication_type: research-article language: en title: Example title of an accepted article contributors: [{ given_names: Ana, family_name: Souza, orcid: 0000-0002-1825-0097, corresponding: true, affiliations: [{ name: Example University }] }] received_date: '2026-07-01' accepted_date: '2026-09-01' license_code: CC-BY-4.0 responses: '200': description: Request completed (or ValidationResult with validate_only) headers: { X-Request-Id: { $ref: '#/components/headers/RequestId' } } content: application/json: schema: oneOf: - $ref: '#/components/schemas/Registration' - $ref: '#/components/schemas/ValidationResult' examples: reserved: { value: { id: 3f0c2a8e-8a9b-4d5e-9f10-2b3c4d5e6f70, status: completed, request_type: create, target_status: reserved, identifier: 'ORI:10001/2026.0001', errors: {}, decision_note: null, submitted_at: '2026-10-16 12:00:00', decided_at: null } } validate_only: { value: { valid: true, request_type: create, target_status: reserved, identifier: null, requires_approval: false } } '202': description: Waiting for approval by the journal reviewers content: { application/json: { schema: { $ref: '#/components/schemas/Registration' } } } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Error' } '409': { $ref: '#/components/responses/Error' } '422': { $ref: '#/components/responses/ValidationError' } '429': { $ref: '#/components/responses/RateLimited' } /registrations/{id}: get: tags: [Registrations] summary: Follow a request (pending, completed with the identifier, rejected or failed with the errors) security: [{ signature: [registrations:read] }, { bearer: [registrations:read] }] parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': description: Request content: { application/json: { schema: { $ref: '#/components/schemas/Registration' } } } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/Error' } /records: get: tags: [Records] summary: List public records parameters: - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/PerPage' - { name: journal, in: query, schema: { type: integer } } - { name: year, in: query, schema: { type: integer } } - { name: status, in: query, schema: { type: string, enum: [published, withdrawn, retracted] } } responses: '200': description: Page of records headers: X-WP-Total: { schema: { type: integer } } X-WP-TotalPages: { schema: { type: integer } } content: application/json: schema: { type: array, items: { $ref: '#/components/schemas/Record' } } '429': { $ref: '#/components/responses/RateLimited' } post: tags: [Records] summary: Create a record directly (draft, reserved or published) description: Journals that require approval cannot issue identifiers here; they use POST /registrations. security: [{ signature: [records:write] }, { bearer: [records:write] }] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RecordInput' } responses: '201': description: Record created content: { application/json: { schema: { $ref: '#/components/schemas/Record' } } } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Error' } '409': { $ref: '#/components/responses/Error' } '422': { $ref: '#/components/responses/ValidationError' } '429': { $ref: '#/components/responses/RateLimited' } /records/{identifier}: parameters: - $ref: '#/components/parameters/Identifier' get: tags: [Records] summary: Read a record description: | Without credentials: the public record (published, withdrawn or retracted). With a credential of the journal (records:read, records:write or records:status): the journal's view, including drafts and reserved records, with "version" for If-Match. The identifier may also be the record UUID. security: [{}, { signature: [records:read] }, { bearer: [records:read] }] responses: '200': description: Record content: { application/json: { schema: { $ref: '#/components/schemas/Record' } } } '404': { $ref: '#/components/responses/Error' } patch: tags: [Records] summary: Update metadata (never changes the identifier) security: [{ signature: [records:write] }, { bearer: [records:write] }] parameters: - { name: If-Match, in: header, required: true, description: 'Current record version (field "version").', schema: { type: string } } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RecordInput' } responses: '200': description: Record updated content: { application/json: { schema: { $ref: '#/components/schemas/Record' } } } '404': { $ref: '#/components/responses/Error' } '409': { $ref: '#/components/responses/Error' } '412': { $ref: '#/components/responses/Error' } '422': { $ref: '#/components/responses/ValidationError' } '428': { $ref: '#/components/responses/Error' } /records/{identifier}/status: parameters: - $ref: '#/components/parameters/Identifier' post: tags: [Records] summary: Change status description: | reserved and published need records:write (journals without approval). withdrawn and retracted need the optional records:status scope and a reason, shown on the public page. Withdrawing a reserved record cancels its number (never reused). security: [{ signature: [records:write, records:status] }, { bearer: [records:write, records:status] }] parameters: - { name: If-Match, in: header, required: false, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [status] properties: status: { type: string, enum: [reserved, published, withdrawn, retracted] } reason: { type: string, description: 'Required for withdrawn and retracted.' } example: { status: retracted, reason: 'Retracted by the journal after an investigation of data integrity.' } responses: '200': description: Record content: { application/json: { schema: { $ref: '#/components/schemas/Record' } } } '403': { $ref: '#/components/responses/Error' } '404': { $ref: '#/components/responses/Error' } '409': { $ref: '#/components/responses/Error' } '412': { $ref: '#/components/responses/Error' } '422': { $ref: '#/components/responses/ValidationError' } /records/{identifier}/files: parameters: - $ref: '#/components/parameters/Identifier' post: tags: [Files] summary: Deposit a file (e.g. the final PDF) description: | The file is checked for security (complete PDF, no scripts or attachments), stored with a SHA-256 checksum and a replica. Needs registrations:write together with records:read (the default set; reserved or published records), or records:write. security: [{ signature: [registrations:write, records:read] }, { bearer: [registrations:write, records:read] }] requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: { type: string, format: binary } kind: { type: string, default: fulltext } version: { type: string, default: published } embargo_until: { type: string, format: date } label: { type: string } responses: '201': description: File stored content: application/json: schema: type: object properties: id: { type: string, format: uuid } kind: { type: string } version: { type: string } name: { type: string } mime: { type: string } size: { type: integer } sha256: { type: string } embargo_until: { type: [string, 'null'] } url: { type: string, format: uri } '400': { $ref: '#/components/responses/Error' } '404': { $ref: '#/components/responses/Error' } '422': { $ref: '#/components/responses/ValidationError' } /journals: get: tags: [Journals] summary: Directory of active journals responses: '200': description: Journals content: application/json: schema: { type: array, items: { $ref: '#/components/schemas/Journal' } } /journals/{id}: get: tags: [Journals] parameters: - { name: id, in: path, required: true, schema: { type: integer } } responses: '200': description: Journal content: { application/json: { schema: { $ref: '#/components/schemas/Journal' } } } '404': { $ref: '#/components/responses/Error' } /resolve/{identifier}: get: tags: [Resolution] summary: Resolve the identifier to its target addresses parameters: - $ref: '#/components/parameters/Identifier' responses: '200': description: Targets content: application/json: schema: type: object properties: identifier: { type: string, example: 'ORI:10001/2026.0001' } status: { type: string, enum: [published, withdrawn, retracted] } record_url: { type: string, format: uri } article_url: { type: [string, 'null'], format: uri } doi: { type: [string, 'null'] } '404': { $ref: '#/components/responses/Error' } /search: get: tags: [Records] summary: Search public records (metadata) parameters: - { name: q, in: query, schema: { type: string } } - { name: journal, in: query, schema: { type: integer } } - { name: year, in: query, schema: { type: integer } } - { name: status, in: query, schema: { type: string } } - { name: lang, in: query, schema: { type: string } } - { name: type, in: query, schema: { type: string } } - { name: sort, in: query, schema: { type: string, enum: [relevance, newest, oldest, title] } } - $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/PerPage' responses: '200': description: Results and facets content: application/json: schema: type: object properties: total: { type: integer } page: { type: integer } pages: { type: integer } results: { type: array, items: { type: object } } facets: { type: object } components: securitySchemes: signature: type: apiKey in: header name: X-ORI-Signature description: | Recommended. Send X-ORI-Key (16 hex), X-ORI-Timestamp (Unix seconds, ±300 s), X-ORI-Nonce (16–64 of A–Z a–z 0–9 - _, once per credential) and X-ORI-Signature = hex HMAC-SHA256(secret, canonical request), see the description of the API. Available for credentials issued from Registry 0.38 (rotate older ones). bearer: type: http scheme: bearer description: 'Journal credential ori__ (also accepted in X-ORI-Credential). HTTPS only.' headers: RequestId: description: Identifier of the request (the X-Request-Id sent, or a new UUID). Quote it when reporting a problem. schema: { type: string } parameters: Identifier: name: identifier in: path required: true description: Institutional identifier as two path segments (10001/2026.0001, optionally prefixed ORI:), an earlier identifier ORI-XXXXXXXXXXXX, or the record UUID on authenticated calls. schema: { type: string, example: '10001/2026.0001' } IdempotencyKey: name: Idempotency-Key in: header required: false description: 'Up to 64 of A–Z a–z 0–9 _ - : . — the same key with the same credential returns the same request (safe retries).' schema: { type: string, maxLength: 64 } RequestIdIn: name: X-Request-Id in: header required: false description: Optional id of the caller (8–64 of A–Z a–z 0–9 _ - . :), echoed in the response. schema: { type: string } Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } PerPage: { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } responses: Error: description: Error content: application/json: schema: { $ref: '#/components/schemas/Error' } Unauthorized: description: | Not authenticated: ori_unauthorized (missing, invalid, revoked or expired credential, or journal/organization suspended), ori_invalid_signature, ori_stale_timestamp, ori_replay, ori_signature_required, ori_signing_unavailable. content: application/json: schema: { $ref: '#/components/schemas/Error' } RateLimited: description: Too many requests (ori_rate_limited) headers: Retry-After: { schema: { type: integer }, description: Seconds to wait. } content: application/json: schema: { $ref: '#/components/schemas/Error' } ValidationError: description: Invalid metadata (ori_validation_failed), with the errors per field content: application/json: schema: { $ref: '#/components/schemas/Error' } example: { code: ori_validation_failed, message: Invalid metadata., data: { status: 422, errors: { title: [Enter the title.] } } } schemas: Scope: type: string enum: [registrations:write, registrations:read, records:read, records:write, records:status] Error: type: object properties: code: type: string description: | ori_unauthorized, ori_invalid_signature, ori_stale_timestamp, ori_replay, ori_signature_required, ori_signing_unavailable (401); ori_forbidden, ori_https_required (403); ori_not_found (404); ori_conflict, ori_invalid_transition (409); ori_precondition_required (428); ori_validation_failed (422); ori_rate_limited (429); ori_error (500); ori_plan_required (402, not used while issuance is free). message: { type: string } data: type: object properties: status: { type: integer } errors: { type: object, additionalProperties: { type: array, items: { type: string } } } reason: { type: string, description: 'For 409: duplicate, source_ref or version.' } retry_after: { type: integer } request_id: { type: string } ValidationResult: type: object properties: valid: { type: boolean, const: true } request_type: { type: string, enum: [create, update] } target_status: { type: string, enum: [reserved, published] } identifier: { type: [string, 'null'], description: 'Identifier of the record already registered with this source_ref.' } requires_approval: { type: boolean } Contributor: type: object required: [family_name] properties: given_names: { type: string } family_name: { type: string } orcid: { type: [string, 'null'], pattern: '^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$' } role: { type: string, enum: [author, editor, translator, collaborator] } corresponding: { type: boolean } credit_roles: { type: array, items: { type: string } } affiliations: type: array items: type: object properties: name: { type: string } ror: { type: [string, 'null'] } RecordInput: type: object required: [publication_type, title] description: journal_id is taken from the credential and ignored when sent with one. properties: source_ref: { type: string, description: 'Identifier of the article in the editorial system (e.g. submission id). Same source_ref = same record.' } publication_type: { type: string, example: research-article } language: { type: string, example: en } title: { type: string } subtitle: { type: string } translated_titles: { type: array, items: { type: object, properties: { lang: { type: string }, title: { type: string } } } } abstract: { type: string } translated_abstracts: { type: array, items: { type: object, properties: { lang: { type: string }, abstract: { type: string } } } } keywords: { type: array, items: { oneOf: [{ type: string }, { type: object, properties: { lang: { type: string }, keyword: { type: string } } }] } } contributors: { type: array, items: { $ref: '#/components/schemas/Contributor' } } volume: { type: string } issue: { type: string } first_page: { type: string } last_page: { type: string } elocation_id: { type: string } received_date: { type: string, format: date } accepted_date: { type: string, format: date } published_date: { type: string, format: date, description: 'Required to publish.' } license_code: { type: string, example: CC-BY-4.0 } license_url: { type: string, format: uri } article_url: { type: string, format: uri, description: 'Official article page; required to publish (ORI checks that it responds).' } fulltext_url: { type: string, format: uri, description: 'External PDF address (or deposit the file).' } funding: { type: array, items: { type: object, properties: { funder_name: { type: string }, funder_id: { type: string }, award_ids: { type: array, items: { type: string } } } } } conflicts_of_interest: { type: string } doi: { type: string, description: 'Only a registered DOI.' } external_ids: { type: array, items: { type: object, properties: { scheme: { type: string }, value: { type: string } } } } references: { type: array, items: { oneOf: [{ type: string }, { type: object, properties: { citation: { type: string }, doi: { type: string }, url: { type: string } } }] } } relations: { type: array, items: { type: object, properties: { type: { type: string }, target_identifier: { type: string }, target_scheme: { type: string }, target_value: { type: string } } } } Record: allOf: - $ref: '#/components/schemas/RecordInput' - type: object properties: uuid: { type: string, format: uuid } identifier: { type: [string, 'null'], example: 'ORI:10001/2026.0001' } status: { type: string, enum: [draft, reserved, published, withdrawn, retracted] } status_reason: { type: [string, 'null'] } version: { type: integer } record_url: { type: [string, 'null'], format: uri, description: 'Only for public records.' } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } Journal: type: object properties: id: { type: integer } title: { type: string } abbreviation: { type: string } issn_print: { type: [string, 'null'] } issn_electronic: { type: [string, 'null'] } homepage_url: { type: string, format: uri } organization: { type: string } records: { type: integer } Registration: type: object properties: id: { type: string, format: uuid } status: { type: string, enum: [pending, approved, rejected, completed, failed] } request_type: { type: string, enum: [create, update] } target_status: { type: string, enum: [reserved, published] } identifier: { type: [string, 'null'] } errors: { type: object, description: 'Errors per field when failed.' } decision_note: { type: [string, 'null'] } submitted_at: { type: string } decided_at: { type: [string, 'null'] }