Build
API reference
Every endpoint, its parameters, examples and errors — labelled live or planned.
Base URL https://pay.animica.dev. JSON in, JSON out, no cookies, no CSRF token — the public API is header-authenticated, and an API key is never accepted from a cookie.
Every endpoint #
| Method | Path | Status | Area |
|---|---|---|---|
POST | /api/v1/payment-intents | live | Payment intents |
GET | /api/v1/payment-intents | live | Payment intents |
GET | /api/v1/payment-intents/{id} | live | Payment intents |
POST | /api/v1/payment-intents/{id}/cancel | live | Payment intents |
POST | /api/v1/refunds | live | Refunds |
GET | /api/v1/refunds/{id} | live | Refunds |
GET | /api/v1/balance | live | Account |
GET | /c/{id} | live | Hosted checkout |
GET | /c/{id}/events | live | Hosted checkout |
GET | /p/{merchant}/{slug} | live | Payment links |
POST | /p/{merchant}/{slug} | live | Payment links |
GET | /l/{code} | live | Payment links |
GET | /i/{token} | live | Invoices |
POST | /i/{token}/pay | live | Invoices |
GET | /pos | live | Point of sale |
GET | /dashboard | live | Dashboard |
GET | /shop | live | Shop |
GET | /shop/{merchant} | live | Shop |
GET | /widget.js | live | Embeds |
GET | /docs | live | Documentation |
GET | /docs/{page} | live | Documentation |
GET | /docs/search | live | Documentation |
GET | /docs/search-index.json | live | Documentation |
GET | /docs/docs.js | live | Documentation |
Authentication #
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
A key is apk_live_ | apk_test_ | ask_live_ | ask_test_ followed by 32 characters of CSPRNG output. Secret keys are stored only as a keyed hash, so:
- the plaintext is shown once and cannot be recovered;
- an unknown key and a wrong key cost the same work, because the comparison runs against a decoy digest when no record matches;
- a database dump alone does not authenticate — the hash is keyed by a server-side pepper of at least 32 bytes that lives in the environment, not in the database.
A publishable key (apk_) may appear in a browser. A secret key (ask_) may not, ever. Test keys cannot move real ANM: the guard fails closed, so anything not positively identifiable as a live key is refused rather than guessed.
Idempotency #
Send Idempotency-Key on every write. Three outcomes:
| Situation | Result |
|---|---|
| First time this key is seen | the request runs |
| Same key, same body | the first response is replayed byte for byte; nothing runs again |
| Same key, different body | 409 idempotency_key_reused — never the old response |
The third row is the one that is usually missed. order 19482 for 49.99 and order 19482 for 4999.00 are different requests and one of them is a bug or an attack, so answering with the stored response would be worse than an error. Keys are scoped per merchant, so guessing woo_order_1 cannot read another merchant’s response. A request still in flight answers 429 rate_limited with a Retry-After rather than starting a second one — retry the same key after the delay. Branching on a 409 there, or minting a fresh key, is exactly how one order ends up with two intents.
Errors #
Every error has the same envelope. No stack traces, ever — they leak paths and versions.
{
"error": {
"type": "invalid_request",
"code": "invalid_json",
"message": "body is not valid JSON",
"request_id": "9f2c7b1e4d0a8c33"
}
}
| Status | type | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body or parameters. code narrows it: invalid_json, invalid_body, bad_request. |
| 401 | invalid_request | unauthorized — missing, malformed, revoked or wrong-kind key. |
| 403 | invalid_request | forbidden — authenticated but not permitted. |
| 404 | invalid_request | not_found — unknown id, or a row belonging to another merchant. |
| 405 | invalid_request | method_not_allowed — known path, wrong verb. |
| 409 | invalid_request | idempotency_key_reused, or an invalid state transition (not_cancellable, not_refundable). |
| 413 | invalid_request | payload_too_large — bodies are capped at 256 KiB and the cap is applied before buffering. |
| 429 | invalid_request | rate_limited, with Retry-After — a drained token bucket or a request with the same Idempotency-Key still in flight. |
| 500 | api_error | internal_error. The request_id is the only detail returned; the detail is in our logs. |
Quote request_id when you contact support. It is in every error body and it is what we search on.
404 rather than 403 for other merchants #
Asking for another merchant’s resource returns 404, not 403. A 403 would confirm the id exists, which turns an id space into an enumeration oracle. Every query is scoped by merchant id, and ownership is checked after loading rather than trusted from the URL.
Rate limits #
Token buckets on two dimensions at once — per key and per IP — so one attacker with many keys cannot saturate the node and one shared host cannot punish every merchant behind it. A request refused by the IP bucket does not burn the key’s quota.
| Header | Meaning |
|---|---|
X-RateLimit-Limit | bucket capacity (the tighter of the two dimensions) |
X-RateLimit-Remaining | tokens left, floored — you are never told 1 when you have 0.4 |
X-RateLimit-Reset | seconds until the bucket is full again |
Retry-After | present only when the request was refused |
Money on the wire #
- nANM amounts are decimal strings. 1 ANM = 1,000,000,000 nANM.
- Fiat amounts you send are decimal strings with at most 2 decimals (
"49.99"). - Nothing in this API accepts or returns a floating-point amount. If your client library turns
"41825250000000"into a double, fix the client — that value is above 2^53 territory for real orders.
Payment intents #
POST /api/v1/payment-intents #
Status: live. This route is mounted and serving on this deployment.
Create a PaymentIntent and get the hosted checkout URL to send the customer to. This is the one call an integration cannot avoid.
Auth: Bearer secret key (ask_live_… / ask_test_…). A publishable key is refused.
Idempotency: send Idempotency-Key. Required in practice; see Idempotency.
| Parameter | Type | Required | Notes |
|---|---|---|---|
amount | string | yes | Decimal string, never a JSON number. For ANM: an integer nANM string ("41825250000000") or ANM with at most 9 decimal places ("41825.25"). For a fiat currency: at most 2 decimal places ("49.99"). |
currency | string | no | Default ANM. Any other currency needs a configured rate source; without one the call is refused rather than priced from a guess. |
merchant_order_id | string | no | Your own order id, at most 120 characters. Unique per merchant — a second intent for the same order returns the existing one. |
description | string | no | At most 500 characters. Shown on the checkout page as plain text; it is escaped, never interpreted as markup. |
success_url | string | no | Absolute URL, at most 2048 characters. Falls back to your merchant setting. |
cancel_url | string | no | Absolute URL, at most 2048 characters. Falls back to your merchant setting. |
metadata | object | no | Any JSON object up to 4096 bytes encoded. Echoed back on the intent and on every webhook. |
POST /api/v1/payment-intents HTTP/1.1
Host: pay.animica.dev
Authorization: Bearer ask_test_REPLACE_WITH_YOUR_SECRET_KEY
Idempotency-Key: woo_order_19482
Content-Type: application/json
{
"amount": "49.99",
"currency": "USD",
"merchant_order_id": "19482",
"description": "WooCommerce Order #19482",
"success_url": "https://shop.example/checkout/received/19482",
"cancel_url": "https://shop.example/checkout",
"metadata": { "platform": "woocommerce", "woocommerce_order_id": "19482" }
}
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
X-RateLimit-Remaining: 99
{
"object": "payment_intent",
"id": "pay_X9m2ABC",
"status": "AWAITING_PAYMENT",
"mode": "test",
"currency": "USD",
"requested_amount": "49.99",
"anm_amount": "41825250000000",
"anm_amount_formatted": "41825.25",
"merchant_amount": "40988745000000",
"protocol_fee": "836505000000",
"protocol_fee_bps": 200,
"merchant_order_id": "19482",
"description": "WooCommerce Order #19482",
"payment_address": "anim1qqq…",
"payment_reference": "ANMPAY1:JBSWY3DPEHPK3PXPJBSWY3",
"checkout_url": "https://pay.animica.dev/c/pay_X9m2ABC",
"transaction_hash": null,
"confirmations": 0,
"required_confirmations": 12,
"metadata": { "platform": "woocommerce", "woocommerce_order_id": "19482" },
"created_at": "2026-08-07T15:00:00.000Z",
"expires_at": "2026-08-07T15:10:00.000Z",
"completed_at": null
}
- Every nANM amount is a decimal string.
41825250000000parsed as a double loses precision, so a JSON number is refused outright withamount_must_be_string— that is the only place the loss can still be detected. Idempotency-Keyis required, not optional. Send your order id. The same key with the same body replays the original response (with anIdempotent-Replay: trueheader) and creates nothing; the same key with a different body is a409.- The server decides the split. Sending
merchant_address,treasury_address,protocol_fee,merchant_amountorprotocol_fee_bpsis a400 field_not_settablerather than a silently ignored field. - A repeat for a
merchant_order_idthat already has an intent answers200with the existing intent instead of201.
| Status | Code | When |
|---|---|---|
| 400 | idempotency_key_required | The Idempotency-Key header was missing. |
| 400 | amount_must_be_string | amount arrived as a JSON number. |
| 400 | invalid_amount | Not a decimal string, too many decimal places, or not greater than zero. |
| 400 | currency_unsupported | A fiat currency was requested and no rate source is configured. Send ANM with an exact amount instead. |
| 400 | invalid_metadata | Not an object, or larger than 4096 bytes encoded. |
| 400 | invalid_url | success_url or cancel_url is not an absolute http(s) URL. |
| 400 | field_not_settable | A server-determined field was supplied. |
| 401 | unauthorized | Missing, malformed, unknown, revoked or publishable key — every reason returns the same opaque 401 so the response cannot be used to probe which keys exist. A key sent in a cookie is refused for the same request. |
| 403 | forbidden | The account is suspended. |
| 409 | idempotency_key_reused | Same Idempotency-Key, different request body. |
| 429 | rate_limited | Bucket exhausted, or a request with this Idempotency-Key is still in flight. Honour Retry-After and do not create a second intent. |
GET /api/v1/payment-intents #
Status: live. This route is mounted and serving on this deployment.
List your intents, newest first, cursor-paginated and scoped to the key’s merchant and mode.
Auth: Bearer secret key
| Parameter | Type | Required | Notes |
|---|---|---|---|
limit | query | no | 1–100, default 20. |
status | query | no | One of the 11 lifecycle states. An unknown value is a 400 rather than an empty list. |
starting_after | query | no | Cursor: the last id from the previous page. |
merchant_order_id | query | no | Exact match on your own order id. |
GET /api/v1/payment-intents?status=PAID&limit=2 HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
HTTP/1.1 200 OK
{
"object": "list",
"has_more": false,
"data": [ { "object": "payment_intent", "id": "pay_X9m2ABC", "status": "PAID" } ]
}
| Status | Code | When |
|---|---|---|
| 400 | invalid_status | Unknown status value. |
GET /api/v1/payment-intents/{id} #
Status: live. This route is mounted and serving on this deployment.
Read one intent. Scoped to your merchant: another merchant’s id returns 404, not 403, so ids cannot be enumerated.
Auth: Bearer secret key
| Parameter | Type | Required | Notes |
|---|---|---|---|
id | path | yes | The pay_… id returned at creation. |
GET /api/v1/payment-intents/pay_X9m2ABC HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
HTTP/1.1 200 OK
{
"object": "payment_intent",
"id": "pay_X9m2ABC",
"status": "PAID",
"anm_amount": "41825250000000",
"merchant_amount": "40988745000000",
"protocol_fee": "836505000000",
"transaction_hash": "0x8f2c…",
"confirmations": 12,
"required_confirmations": 12,
"completed_at": "2026-08-07T15:04:11.000Z"
}
- Poll this if you must, but a webhook is the intended path: polling cannot tell you about a reorg the moment it happens.
| Status | Code | When |
|---|---|---|
| 404 | not_found | Unknown id, or an id belonging to another merchant. |
POST /api/v1/payment-intents/{id}/cancel #
Status: live. This route is mounted and serving on this deployment.
Cancel an intent that has not been paid. Cancelling is a courtesy to the customer, not a guarantee: a transfer already signed can still arrive and settle.
Auth: Bearer secret key
| Parameter | Type | Required | Notes |
|---|---|---|---|
id | path | yes | The pay_… id. |
POST /api/v1/payment-intents/pay_X9m2ABC/cancel HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
HTTP/1.1 200 OK
{ "object": "payment_intent", "id": "pay_X9m2ABC", "status": "EXPIRED" }
| Status | Code | When |
|---|---|---|
| 404 | not_found | Unknown id, or another merchant’s id. |
| 409 | not_cancellable | The intent is in a state that cannot be cancelled — a paid payment is a fact, so refund it instead. |
Refunds #
POST /api/v1/refunds #
Status: live. This route is mounted and serving on this deployment.
Refund a PAID intent, in full or in part. A refund is a NEW ANM transfer — a blockchain payment cannot be reversed.
Auth: Bearer secret key, live mode only
Idempotency: send Idempotency-Key. Required in practice; see Idempotency.
| Parameter | Type | Required | Notes |
|---|---|---|---|
payment_intent | string | yes | The pay_… id to refund. Must be PAID or PARTIALLY_REFUNDED. |
amount | string | no | ANM amount as a decimal string, same rules as on creation. Omit to refund what you received — the merchant amount, not the gross, because the protocol fee is not returned. |
reason | string | no | At most 300 characters, for your records. |
POST /api/v1/refunds HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
Idempotency-Key: woo_refund_19482_10.00
{ "payment_intent": "pay_X9m2ABC", "amount": "10.00", "reason": "damaged item" }
HTTP/1.1 201 Created
{
"object": "refund",
"id": "re_7Kd2",
"payment_intent": "pay_X9m2ABC",
"status": "REQUIRES_SIGNATURE",
"amount": "10000000000",
"amount_formatted": "10",
"reason": "damaged item",
"requires_merchant_signature": true,
"unsigned_transfer": { "to": "anim1…", "amount": "10000000000" },
"transaction_hash": null,
"created_at": "2026-08-08T09:12:00.000Z"
}
requires_merchant_signature: trueis the normal case: we build the transfer, you sign it. Nothing here holds a key that can spend your funds unless you opted into a forwarding address.- A test-mode key cannot create a refund at all — refunds move real ANM, and test mode is not allowed to.
- The protocol fee on the original payment is not returned by a refund. See The 2% fee.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | payment_intent missing, or amount is not a valid decimal string. |
| 404 | not_found | Unknown intent, or one belonging to another merchant. |
| 409 | not_refundable | The payment is not in PAID or PARTIALLY_REFUNDED. |
| 409 | refund_refused | The refund service refused it — typically more than the refundable remainder. |
GET /api/v1/refunds/{id} #
Status: live. This route is mounted and serving on this deployment.
Read one refund, including the unsigned transfer while it is waiting for your signature and the transaction hash once it exists.
Auth: Bearer secret key
| Parameter | Type | Required | Notes |
|---|---|---|---|
id | path | yes | The re_… id. |
GET /api/v1/refunds/re_7Kd2 HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
HTTP/1.1 200 OK
{ "object": "refund", "id": "re_7Kd2", "status": "CONFIRMED",
"amount": "10000000000", "requires_merchant_signature": false,
"transaction_hash": "0x4b1e…" }
| Status | Code | When |
|---|---|---|
| 404 | not_found | Unknown refund, or another merchant’s refund. |
Account #
GET /api/v1/balance #
Status: live. This route is mounted and serving on this deployment.
Settled and pending totals for the key’s mode. The WooCommerce plugin uses this as its connectivity check.
Auth: Bearer secret key
GET /api/v1/balance HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
HTTP/1.1 200 OK
{
"object": "balance",
"mode": "live",
"currency": "ANM",
"settled": "40988745000000",
"settled_formatted": "40988.745",
"pending": "0",
"pending_formatted": "0",
"protocol_fees_paid": "836505000000",
"payout_address": "anim1…"
}
settledis what has confirmed on chain.pendingis detected but not yet deep enough, and treating it as spendable is how a merchant over-ships — they are reported separately for that reason.- Under the non-custodial default the 2% you still owe is tracked in the settlement ledger and shown in the dashboard; see The splitting limitation.
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Bad, revoked or publishable key. |
Hosted checkout #
GET /c/{id} #
Status: live. This route is mounted and serving on this deployment.
The hosted checkout page: amount, payment address, the ANMPAY1 reference to attach, a QR code and live status. checkout_url on a PaymentIntent points here. The only page allowed to be embedded in a merchant site, and then only via a merchant-scoped frame-ancestors.
Auth: none — the unguessable id is the capability
GET /c/{id}/events #
Status: live. This route is mounted and serving on this deployment.
Server-Sent Events for checkout status. SSE from our server, not a chain subscription: the Animica node has no websocket API, so our indexer polls and we push.
Auth: none — the unguessable id is the capability
Payment links #
GET /p/{merchant}/{slug} #
Status: live. This route is mounted and serving on this deployment.
A shareable payment link. No website needed: the page shows the price and a Pay button.
Auth: none (public)
POST /p/{merchant}/{slug} #
Status: live. This route is mounted and serving on this deployment.
One customer pressing Pay: creates the PaymentIntent and redirects to /c/{id}. Rate-limited per IP, because it creates rows. It also takes one unit of a limited link as a 30-minute hold, which returns if the payment never arrives.
Auth: none (public) for a live link; a test-mode link is payable only by its own merchant
GET /l/{code} #
Status: live. This route is mounted and serving on this deployment.
Short-link redirect to the full payment link. Convenient in print and messages.
Auth: none (public)
Invoices #
GET /i/{token} #
Status: live. This route is mounted and serving on this deployment.
A customer-facing invoice: line items, total, and a Pay button.
Auth: none — the unguessable token is the capability
POST /i/{token}/pay #
Status: live. This route is mounted and serving on this deployment.
Creates the PaymentIntent for an invoice and sends the customer to checkout.
Auth: none — the unguessable token is the capability
Point of sale #
GET /pos #
Status: live. This route is mounted and serving on this deployment.
In-person mode: type an amount, show a QR, watch it confirm.
Auth: dashboard session
Dashboard #
GET /dashboard #
Status: live. This route is mounted and serving on this deployment.
Merchant dashboard: payments, keys, settings, webhook secret, payout address, mode switch. Cookie-authenticated and therefore CSRF-protected — unlike the API, which is cookie-free by design.
Auth: dashboard session (cookie + CSRF on every write)
Shop #
GET /shop #
Status: live. This route is mounted and serving on this deployment.
Opt-in public directory of merchants and their already-public payment links. Off by default — a payments processor must not publish its customer list.
Auth: none (public)
GET /shop/{merchant} #
Status: live. This route is mounted and serving on this deployment.
A single storefront. Listing requires a verified payout address, so a listed shop can actually be paid.
Auth: none (public)
Embeds #
GET /widget.js #
Status: live. This route is mounted and serving on this deployment.
Drop-in modal checkout. It takes a publishable key only; a secret key must never appear in browser code.
Auth: publishable key, in the page
Documentation #
GET /docs #
Status: live. This route is mounted and serving on this deployment.
This documentation site. Served by our own router: no third-party docs host, no external asset, no inline script.
Auth: none (public)
GET /docs/{page} #
Status: live. This route is mounted and serving on this deployment.
One documentation page. An unknown page is a 404 that lists the pages which do exist.
Auth: none (public)
GET /docs/search #
Status: live. This route is mounted and serving on this deployment.
Server-rendered search over these pages. Works with JavaScript disabled; the same index powers the instant results in the header.
Auth: none (public)
GET /docs/search-index.json #
Status: live. This route is mounted and serving on this deployment.
The search index the docs page fetches. Public content only.
Auth: none (public)
GET /docs/docs.js #
Status: live. This route is mounted and serving on this deployment.
Copy buttons and instant search. The pages are fully readable without it.
Auth: none (public)