Animica Pay docs 2.00% per successful payment

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 #

MethodPathStatusArea
POST/api/v1/payment-intentslivePayment intents
GET/api/v1/payment-intentslivePayment intents
GET/api/v1/payment-intents/{id}livePayment intents
POST/api/v1/payment-intents/{id}/cancellivePayment intents
POST/api/v1/refundsliveRefunds
GET/api/v1/refunds/{id}liveRefunds
GET/api/v1/balanceliveAccount
GET/c/{id}liveHosted checkout
GET/c/{id}/eventsliveHosted checkout
GET/p/{merchant}/{slug}livePayment links
POST/p/{merchant}/{slug}livePayment links
GET/l/{code}livePayment links
GET/i/{token}liveInvoices
POST/i/{token}/payliveInvoices
GET/poslivePoint of sale
GET/dashboardliveDashboard
GET/shopliveShop
GET/shop/{merchant}liveShop
GET/widget.jsliveEmbeds
GET/docsliveDocumentation
GET/docs/{page}liveDocumentation
GET/docs/searchliveDocumentation
GET/docs/search-index.jsonliveDocumentation
GET/docs/docs.jsliveDocumentation

Authentication #

http
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:

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:

SituationResult
First time this key is seenthe request runs
Same key, same bodythe first response is replayed byte for byte; nothing runs again
Same key, different body409 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.

json
{
  "error": {
    "type": "invalid_request",
    "code": "invalid_json",
    "message": "body is not valid JSON",
    "request_id": "9f2c7b1e4d0a8c33"
  }
}
StatustypeMeaning
400invalid_requestMalformed body or parameters. code narrows it: invalid_json, invalid_body, bad_request.
401invalid_requestunauthorized — missing, malformed, revoked or wrong-kind key.
403invalid_requestforbidden — authenticated but not permitted.
404invalid_requestnot_found — unknown id, or a row belonging to another merchant.
405invalid_requestmethod_not_allowed — known path, wrong verb.
409invalid_requestidempotency_key_reused, or an invalid state transition (not_cancellable, not_refundable).
413invalid_requestpayload_too_large — bodies are capped at 256 KiB and the cap is applied before buffering.
429invalid_requestrate_limited, with Retry-After — a drained token bucket or a request with the same Idempotency-Key still in flight.
500api_errorinternal_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.

HeaderMeaning
X-RateLimit-Limitbucket capacity (the tighter of the two dimensions)
X-RateLimit-Remainingtokens left, floored — you are never told 1 when you have 0.4
X-RateLimit-Resetseconds until the bucket is full again
Retry-Afterpresent only when the request was refused

Money on the wire #

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.

ParameterTypeRequiredNotes
amountstringyesDecimal 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").
currencystringnoDefault ANM. Any other currency needs a configured rate source; without one the call is refused rather than priced from a guess.
merchant_order_idstringnoYour own order id, at most 120 characters. Unique per merchant — a second intent for the same order returns the existing one.
descriptionstringnoAt most 500 characters. Shown on the checkout page as plain text; it is escaped, never interpreted as markup.
success_urlstringnoAbsolute URL, at most 2048 characters. Falls back to your merchant setting.
cancel_urlstringnoAbsolute URL, at most 2048 characters. Falls back to your merchant setting.
metadataobjectnoAny JSON object up to 4096 bytes encoded. Echoed back on the intent and on every webhook.
http
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
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
}
StatusCodeWhen
400idempotency_key_requiredThe Idempotency-Key header was missing.
400amount_must_be_stringamount arrived as a JSON number.
400invalid_amountNot a decimal string, too many decimal places, or not greater than zero.
400currency_unsupportedA fiat currency was requested and no rate source is configured. Send ANM with an exact amount instead.
400invalid_metadataNot an object, or larger than 4096 bytes encoded.
400invalid_urlsuccess_url or cancel_url is not an absolute http(s) URL.
400field_not_settableA server-determined field was supplied.
401unauthorizedMissing, 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.
403forbiddenThe account is suspended.
409idempotency_key_reusedSame Idempotency-Key, different request body.
429rate_limitedBucket 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

ParameterTypeRequiredNotes
limitqueryno1–100, default 20.
statusquerynoOne of the 11 lifecycle states. An unknown value is a 400 rather than an empty list.
starting_afterquerynoCursor: the last id from the previous page.
merchant_order_idquerynoExact match on your own order id.
http
GET /api/v1/payment-intents?status=PAID&limit=2 HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
http
HTTP/1.1 200 OK

{
  "object": "list",
  "has_more": false,
  "data": [ { "object": "payment_intent", "id": "pay_X9m2ABC", "status": "PAID" } ]
}
StatusCodeWhen
400invalid_statusUnknown 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

ParameterTypeRequiredNotes
idpathyesThe pay_… id returned at creation.
http
GET /api/v1/payment-intents/pay_X9m2ABC HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
http
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"
}
StatusCodeWhen
404not_foundUnknown 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

ParameterTypeRequiredNotes
idpathyesThe pay_… id.
http
POST /api/v1/payment-intents/pay_X9m2ABC/cancel HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
http
HTTP/1.1 200 OK

{ "object": "payment_intent", "id": "pay_X9m2ABC", "status": "EXPIRED" }
StatusCodeWhen
404not_foundUnknown id, or another merchant’s id.
409not_cancellableThe 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.

ParameterTypeRequiredNotes
payment_intentstringyesThe pay_… id to refund. Must be PAID or PARTIALLY_REFUNDED.
amountstringnoANM 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.
reasonstringnoAt most 300 characters, for your records.
http
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
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"
}
StatusCodeWhen
400invalid_requestpayment_intent missing, or amount is not a valid decimal string.
404not_foundUnknown intent, or one belonging to another merchant.
409not_refundableThe payment is not in PAID or PARTIALLY_REFUNDED.
409refund_refusedThe 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

ParameterTypeRequiredNotes
idpathyesThe re_… id.
http
GET /api/v1/refunds/re_7Kd2 HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
http
HTTP/1.1 200 OK

{ "object": "refund", "id": "re_7Kd2", "status": "CONFIRMED",
  "amount": "10000000000", "requires_merchant_signature": false,
  "transaction_hash": "0x4b1e…" }
StatusCodeWhen
404not_foundUnknown 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

http
GET /api/v1/balance HTTP/1.1
Authorization: Bearer ask_live_REPLACE_WITH_YOUR_SECRET_KEY
http
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…"
}
StatusCodeWhen
401unauthorizedBad, 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

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)

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)