Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Attestwire Keys API
version: 0.1.0
summary: EN 16931 e-invoice validation and generation.
description: 'Validate an invoice against EN 16931 and its national CIUS rules (XRechnung, Peppol BIS 3) and get back errors that teach the regulation: every failure carries the official rule id, the business term it constrains, what the rule requires, and how to fix it. Invoices go in and come out in both EN 16931 syntaxes, UBL 2.1 and UN/CEFACT CII; the profile chooses the syntax on generate, and on validate the document''s root element does. The `facturx-en16931` profile is checked against core EN 16931 only — there are no Factur-X-specific rules. PDF: Factur-X and ZUGFeRD files are CII XML inside a PDF/A-3 container. POST /v1/validate and POST /v1/parse open one sent as `application/pdf`, on every plan, and read the XML attached to it; /v1/validate also reports on the PDF. Of the PDF itself, the attachment (its name, /AFRelationship and MIME type) and the XMP metadata are checked, and each problem is an AW-PDF-* finding, none of them fatal; PDF/A conformance itself, and whether the page agrees with the XML, are not checked. The API builds one in exactly one case, profile `facturx-en16931`: a Factur-X / ZUGFeRD PDF (EN 16931 profile), a page drawn from the invoice, written as PDF/A-3B, with the CII embedded as `factur-x.xml` — watermarked as a preview on the free plan, and without the watermark on a paid plan. `POST /v1/generate?format=pdf` returns it, `POST /v1/bundle?format=pdf` puts it in an evidence bundle beside the XML and a signed validation report, and `POST /v1/send` with `"format": "pdf"` e-mails it. Every other invoice the API writes is XML. Sending: `POST /v1/send` e-mails one invoice to one address on request; the API does not operate Peppol or French-platform transmission.'
contact:
name: Attestwire
url: https://attestwire.com
license:
name: Proprietary
servers:
- url: https://api.attestwire.com
description: Production
security:
- bearerAuth: []
tags:
- name: Keys
description: Self-serve API key issuance.
paths:
/v1/keys:
post:
tags:
- Keys
operationId: createKey
summary: Issue a free-tier API key
description: 'No authentication. One free key per email address — the plaintext key is returned exactly once and is not recoverable afterwards. Send YOUR address: whatever you post here is claimed permanently (we keep only its hash, so it can never be re-issued), and the example below is a placeholder on the reserved `.example` TLD, not an address to send as-is.'
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
properties:
email:
type: string
format: email
maxLength: 254
examples:
signup:
summary: REPLACE with your own address — it is claimed permanently.
value:
email: you@your-company.example
responses:
'201':
description: Key created. Store `key` now; it is never shown again.
content:
application/json:
schema:
type: object
required:
- key
- tier
- monthly_limit
- created
- warning
properties:
key:
type: string
examples:
- aw_live_xxxxxxxx
tier:
type: string
enum:
- free
- solo
- starter
- scale
monthly_limit:
type: integer
created:
type: string
format: date-time
warning:
type: string
examples:
created:
summary: The only time the plaintext key exists on our side.
value:
key: aw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
tier: free
monthly_limit: 100
created: '2026-08-11T09:12:33.104Z'
warning: Store this key now. It is shown once and cannot be recovered.
'400':
description: Missing or malformed email address (`invalid_email`), or a body that is not a JSON object (`bad_request`). Checked BEFORE the per-IP limiter, so a typo does not spend one of your five daily signups.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalid_email:
summary: Not an address
value:
error: invalid_email
message: That does not look like an email address.
docs: https://api.attestwire.com/docs
'409':
description: A free key already exists for this email address, and we store only its hash — it cannot be shown again. If you still hold it, replace it with POST /v1/keys/rotate.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
key_already_issued:
summary: One free key per address
value:
error: key_already_issued
message: 'A free key already exists for this email address. If you still hold the key, replace it yourself with POST /v1/keys/rotate — your plan and usage carry over. If it is lost, a different email address gets you a working free key immediately, and a plus-alias counts. On a paid plan, email hello@attestwire.com from the address on your Paddle billing receipt and the replacement comes back to that address. Either way we cannot send you the old key: we store only its SHA-256 hash, so there is nothing to show again.'
docs: https://api.attestwire.com/docs#rotation
'413':
description: The request body is over 1 MB. Nothing was read and nothing was charged.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
payload_too_large:
summary: Body over 1 MB
value:
error: payload_too_large
message: Request body is over 1 MB.
docs: https://api.attestwire.com/docs
'429':
description: Too many key requests from this IP address today.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
too_many_requests:
summary: Five free keys per IP per UTC day
value:
error: too_many_requests
message: That is 5 key requests from this address today. If you need more, email hello@attestwire.com rather than looping.
docs: https://api.attestwire.com/docs#limits
'500':
description: Unhandled fault while issuing the key. Never expected; the body carries no detail on purpose (a fault message can quote the offending value). Retry, and email hello@attestwire.com if it persists.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
internal_error:
summary: Unhandled fault
value:
error: internal_error
message: Something broke on our side. Try again.
/v1/keys/rotate:
post:
tags:
- Keys
operationId: rotateKey
summary: Replace the calling key with a new one
description: 'Authenticated by the key being replaced — that bearer token IS the proof of ownership, and there is deliberately no email-triggered variant. No request body. Consumes no documents.
Everything follows the new key atomically: tier and limit, the current period''s usage (rotation does NOT reset your quota), any past_due flag, the original `created` date, and the subscription link — including billing events that were already in flight when you rotated.
The plaintext key is returned exactly once, and the old key answers 410 `key_rotated` from the moment this call succeeds. Deploy first, then rotate. Limited to 3 rotations per key per UTC day; the limit follows the key through a rotation, so chaining does not reset it. Two simultaneous calls cannot both succeed — the loser gets 409.
If you no longer HAVE the key, this endpoint is closed to you — there is nothing to authenticate the call with. What is available instead depends on the tier, and on the free tier it is still immediate.
If you are on a paid plan and the key is gone, email hello@attestwire.com from the address on your Paddle billing receipt. We check the request against Paddle''s record of your subscription rather than against the receipt itself — a receipt can be forwarded, so attaching one proves only that somebody has a copy of it — and the replacement is delivered one way only: as a reply to the billing address on file, whoever did the asking. Whoever handles it retires the old key and issues the new one.
We cannot recover a lost free key — not as a matter of policy, but because we never collected anything that could prove it was yours: a free signup address is never verified, and the key itself only ever existed in one HTTP response. The practical answer is faster than support anyway. The limit is one free key per email address, so a different address gets you a working free key on the spot, and a plus-alias such as you+attestwire@example.com counts. That limit keeps casual signups to one key each; it is housekeeping rather than a security control, and it was never meant to trap you. If you would rather we were able to rescue you, that is part of what a paid plan buys: Paddle holds a billing identity we can check.'
responses:
'200':
description: Rotated. Store `key` now; it is never shown again. The other fields are the continuity proof — same tier, same usage.
content:
application/json:
schema:
type: object
required:
- key
- tier
- monthly_limit
- period
- used
- rotated_at
properties:
key:
type: string
examples:
- aw_live_xxxxxxxx
tier:
type: string
enum:
- free
- solo
- starter
- scale
monthly_limit:
type: integer
period:
type: string
examples:
- 2026-08
used:
type: integer
description: Documents already used this period, carried over from the old key.
remaining:
type: integer
resets_at:
type: string
format: date-time
created:
type:
- string
- 'null'
format: date-time
description: Original signup date, unchanged by rotation.
rotated_at:
type: string
format: date-time
rotations_remaining_today:
type: integer
warning:
type: string
examples:
rotated:
summary: The old key is dead from this response onward.
value:
key: aw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
tier: starter
monthly_limit: 5000
period: 2026-08
used: 418
remaining: 4582
resets_at: '2026-09-01T00:00:00.000Z'
created: '2026-01-15T09:12:04.000Z'
rotated_at: '2026-08-11T10:04:31.000Z'
rotations_remaining_today: 2
warning: Store this key now. It is shown once and cannot be recovered. The previous key stopped working when this response was generated and now answers 410 key_rotated.
'401':
description: Missing or unknown API key.
headers:
WWW-Authenticate:
schema:
type: string
examples:
- Bearer
description: Always `Bearer`.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
missing_api_key:
summary: No Authorization header
value:
error: missing_api_key
message: 'Send your key as an Authorization: Bearer header. Get one free at POST /v1/keys.'
docs: https://api.attestwire.com/docs#auth
invalid_api_key:
summary: Bearer token we do not recognise
value:
error: invalid_api_key
message: 'That API key is not recognised. Check that you pasted the whole key: it is aw_live_ followed by 40 characters, and a truncated copy is the usual cause. If you rotated this key, use the key that the rotation returned. If you no longer have a key, a new one is free at POST /v1/keys.'
docs: https://api.attestwire.com/docs#auth
'409':
description: '`rotation_already_done` — this key has already been rotated, or a concurrent rotate call won the race. The replacement was returned to that call.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
rotation_already_done:
summary: The replacement exists and this call is not the one that sees it
value:
error: rotation_already_done
message: This key has already been rotated, so it cannot be rotated again. The replacement was returned exactly once, in the response to the request that performed the rotation — if two rotations were sent at the same time, the other response has your new key. If it is lost, email hello@attestwire.com from the address on your Paddle billing receipt.
docs: https://api.attestwire.com/docs#rotation
'410':
description: The key presented was retired by an earlier rotation. Its replacement is the live credential; this one is permanently gone, so retrying with it cannot work. Every authenticated endpoint answers this, not just /v1/keys/rotate.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
key_rotated:
summary: The key was replaced by POST /v1/keys/rotate
value:
error: key_rotated
message: This API key was rotated on 2026-08-01 and no longer works. Use the replacement key returned by that rotation call — it was shown once and we cannot show it again. If you did not rotate it, or the replacement is lost, email hello@attestwire.com from the address on your Paddle billing receipt.
docs: https://api.attestwire.com/docs#rotation
'429':
description: '`rotation_rate_limited` — more than 3 rotations of this key today.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
rotation_rate_limited:
summary: The daily ceiling, which a rotation does not reset
value:
error: rotation_rate_limited
message: This key has already been rotated 3 times today, which is the daily limit. The limit follows the key through a rotation, so rotating again does not reset it. It clears at 00:00 UTC. If you are locked out of your own key, email hello@attestwire.com from the address on your Paddle billing receipt.
docs: https://api.attestwire.com/docs#rotation
'500':
description: 'Two different faults share this status and they mean different things. `error: "rotation_failed"` — a step of the rotation failed and the whole thing was unwound: NOTHING changed, your existing key still works, and it is safe to retry. `error: "internal_error"` — the unhandled fault every route can answer, which here leaves the outcome UNKNOWN. Do not rotate again on that one; call GET /v1/usage with the old key first. A `410 key_rotated` proves the rotation completed, and the replacement key was spent on the response you never got — email hello@attestwire.com rather than rotating again. A `200` is evidence it did not, but not proof: that lookup reads an eventually-consistent store, so a rotation that did land can still answer 200 for a short window. Re-check after a minute and only rotate again once 200 is stable.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
rotation_failed:
summary: Rotation unwound. The old key still works — this is the recoverable one.
value:
error: rotation_failed
message: The rotation could not be completed and nothing was changed. Your existing key still works; try again.
docs: https://api.attestwire.com/docs#rotation
internal_error:
summary: Unhandled fault. Outcome unknown.
value:
error: internal_error
message: Something broke on our side. Try again.
/v1/usage:
get:
tags:
- Keys
operationId: getUsage
summary: Current period usage for the calling key
description: Free, and does not consume a document.
responses:
'200':
description: Usage for the current UTC month.
content:
application/json:
schema:
type: object
required:
- tier
- period
- used
- limit
- remaining
- resets_at
- past_due
properties:
tier:
type: string
period:
type: string
examples:
- 2026-08
used:
type: integer
limit:
type: integer
remaining:
type: integer
resets_at:
type: string
format: date-time
past_due:
type: boolean
description: True when the last subscription payment failed and the provider is still retrying. The paid allowance is UNCHANGED while this is true; only if every retry fails does the key drop to the free tier. This is the only in-band billing warning the API gives — poll it if you want to detect trouble programmatically.
billing_warning:
type: string
description: Human-readable explanation, present only when past_due is true.
portal_endpoint:
$ref: '#/components/schemas/PortalEndpoint'
tier_expires:
type: string
format: date-time
description: 'Present only on a key whose tier is TIME-LIMITED — today, a key minted by an Invoice Fix Report claim (POST /v1/fix/claim), which carries 30 days of starter. Published both before and after the date passes: once past, `tier` above already reads `free`, and this field is the whole explanation of why. The key itself never expires and is never deleted; only the monthly allowance changes.'
examples:
fix_report:
summary: A key from a Fix Report claim, still inside its 30 days.
value:
tier: starter
period: 2026-08
used: 40
limit: 5000
remaining: 4960
resets_at: '2026-09-01T00:00:00.000Z'
past_due: false
tier_expires: '2026-09-14T09:12:33.104Z'
free:
summary: A free key. No subscription, so no portal endpoint.
value:
tier: free
period: 2026-08
used: 12
limit: 100
remaining: 88
resets_at: '2026-09-01T00:00:00.000Z'
past_due: false
'401':
description: Missing or unknown API key.
headers:
WWW-Authenticate:
schema:
type: string
examples:
- Bearer
description: Always `Bearer`.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
missing_api_key:
summary: No Authorization header
value:
error: missing_api_key
message: 'Send your key as an Authorization: Bearer header. Get one free at POST /v1/keys.'
docs: https://api.attestwire.com/docs#auth
invalid_api_key:
summary: Bearer token we do not recognise
value:
error: invalid_api_key
message: 'That API key is not recognised. Check that you pasted the whole key: it is aw_live_ followed by 40 characters, and a truncated copy is the usual cause. If you rotated this key, use the key that the rotation returned. If you no longer have a key, a new one is free at POST /v1/keys.'
docs: https://api.attestwire.com/docs#auth
'410':
description: The key presented was retired by an earlier rotation. Its replacement is the live credential; this one is permanently gone, so retrying with it cannot work. Every authenticated endpoint answers this, not just /v1/keys/rotate.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
key_rotated:
summary: The key was replaced by POST /v1/keys/rotate
value:
error: key_rotated
message: This API key was rotated on 2026-08-01 and no longer works. Use the replacement key returned by that rotation call — it was shown once and we cannot show it again. If you did not rotate it, or the replacement is lost, email hello@attestwire.com from the address on your Paddle billing receipt.
docs: https://api.attestwire.com/docs#rotation
'500':
description: Unhandled fault while reading the meter. Never expected; the body carries no detail on purpose (a fault message can quote the offending value). Retry, and email hello@attestwire.com if it persists.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
internal_error:
summary: Unhandled fault
value:
error: internal_error
message: Something broke on our side. Try again.
components:
schemas:
Error:
type: object
required:
- error
- message
properties:
error:
type: string
description: Stable machine-readable code. Branch on this, not on `message`.
message:
type: string
description: Human-readable explanation.
docs:
type: string
format: uri
upgrade_url:
type: string
format: uri
description: 'Present on `quota_exceeded` only. The page where a human buys a bigger plan. It is a plain link, not a checkout transaction: nothing is bought and no billing state is created by reading it. Absent on every other error code.'
examples:
- https://attestwire.com/pricing#upgrade
xml_code:
type: string
description: 'Present when POST /v1/validate or POST /v1/parse (or /v1/bundle, or /v1/send''s `document`) was sent a document that could not be read and the engine''s reader raised an error. It is that reader''s own stable code, echoed unchanged, and it is finer-grained than `error`: a whole family of well-formedness defects shares `error: "xml_malformed"` and each member carries its own `xml_code`. For a PDF the reader is the PDF one, and the codes start `pdf_` or `facturx_` — the name of the field predates PDFs. Absent when the file was recognised on sight as something else (an HTML page, a ZIP archive), where the reader raised nothing. The list below is a sample, not the full set, and it is deliberately open — the reader gains codes as it learns to refuse more, and a caller that switches exhaustively on this field will break when it does. Branch on `error` first and on this only if you need the detail.'
examples:
- unsupported_syntax
- xml_doctype_forbidden
- xml_entity_forbidden
- xml_too_deep
- xml_too_many_elements
- xml_too_many_attributes
- xml_unbalanced
- xml_mixed_content
- xml_duplicate_attribute
- xml_bad_comment
- facturx_no_xml_attachment
- facturx_xml_encoding
- pdf_no_startxref
- pdf_stream_too_large
PortalEndpoint:
type: string
description: 'The ROUTE that mints a billing-portal link — not a link. Always `/v1/billing/portal`. Call it with the customer''s own key and it returns a one-time URL where they change plan, update the card, download invoices and cancel.
It is an endpoint rather than a URL because Paddle has no static portal page: each link is a session bound to one customer and it expires, so there is nothing durable to publish here, and minting one on every `/v1/usage` poll would spend a live API call on a link nobody opens.
Present on `/v1/usage` only for keys that actually have a subscription — a free key has nothing to manage, and pointing one at a route that will answer 409 is worse than saying nothing. Absent, never null.'
examples:
- /v1/billing/portal
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: 'Send your key as `Authorization: Bearer aw_live_...`. The public demo key, `Authorization: Bearer demo`, works on POST /v1/validate, POST /v1/generate and POST /v1/recipient only, with no account: 6 requests a minute and 30 documents a day per client IP, its PDF always watermarked, and every other route answers `403 demo_key_not_allowed`.'
externalDocs:
description: Hand-written docs, the quickstart, and the per-rule pages at attestwire.com/rules/BR-*. This deployment also serves /llms.txt for agents and an MCP endpoint at /mcp, neither of which is describable in OpenAPI.
url: https://api.attestwire.com/docs