Money
Every amount on the wire is a decimal string in the asset’s major unit:"500.00", "0.9247",
"1204.500000". Not a JSON number, not an atomic-unit integer, never exponent notation.
Precision is refused, never rounded. More fractional digits than the asset allows answers
422 validation_failed with details.fields[0].code = "amount_precision" — so a rounding error can
never reach a payment. Rates and fees follow the same rule; feeBps is the one exception and is an
integer number of basis points.
Where an operation has two sides, you pin one and HEVN derives the other. Send amount to pin what
leaves the balance or amountTo to pin what the beneficiary receives — one of them, never both.
Ids
Every id is prefixed, opaque and safe to store as text:
A
dk_… is the one id you never send to this API: it names a developer key registration and belongs
to the management routes on the ordinary API (Developer key).
Do not parse them, derive them or assume a length. An id of the wrong kind in a body or query field
is 400 invalid_id with the expected prefix in details; in a path it is that resource’s own 404,
so an id from another namespace is never confirmable.
Rail ids — sepa_named-bank_a, a stand-in for a real one — are the exception that proves the rule:
they are opaque strings you copy from GET /dapi/v1/banks, they carry no prefix, and their shape is
not a contract. See Rails.
Casing
camelCase everywhere: request bodies, query parameters, response fields and the keys insideerror.details. Two things are deliberately not field names. An error.code slug is always
lowercase snake_case. An enum member usually is too — business_revenue — but an enum member is a
literal, not a naming convention: HighRiskActivity members are SCREAMING_SNAKE_CASE
(MONEY_SERVICES, PRECIOUS_METALS_STONES_JEWELRY), and FiatTransactionCount members are the
ranges they read as (<10, 10-50, 50-200, 200-1,000, 1,000+, punctuation included). Send and
compare enum values exactly as the spec prints them, and never case-fold them.
Two consequences worth knowing before your first 422:
- Unknown keys are refused. A body with a field this version does not define answers
422 validation_failedwithcode: "extra_forbidden"rather than ignoring it, so a typo cannot silently drop a payment reference. The two nested objects that predate this rule — a contact’saddressand a requisite’sbank/payerblock — still ignore a key they do not know, so a misspeltstreetAddressthere resurfaces one step later as422 contact_details_invalidnaming the field it never received. - Null fields are omitted. “Absent” and “null” are the same thing; a response never prints
"phone": null. Treat a missing key as unset.
fields on
PUT /dapi/v1/documents/{documentId}/content are the extractor’s own field names —
registration_number, address.street_address — because they are values read off a page rather
than fields of this API. They are the keys the refusal that asks for them already named. See
Onboard a client.
Timestamps are RFC 3339 in UTC with a Z suffix: 2026-09-17T10:04:11Z.
Idempotency-Key
Every write acceptsIdempotency-Key, matching ^[A-Za-z0-9._:-]{1,128}$. No operation requires it.
Send one whenever a retry must not become a second payment. Derive it from an id your own system
owns, and keep it identical across every retry of the same request.
Derive Idempotency-Key from an id your own system already owns, pass it in from the caller, and
keep it identical across every retry of the same payment.
idempotencyKey, and treats an identical repeat as a replay. The difference is the window: a key you
chose anchors the request for 7 days, a derived one for 2 hours — long enough for a retry,
short enough that two genuinely separate payments of the same amount both happen.
A key is scoped to your account and the operation, so two integrators can never collide and the same
key on POST /payouts and POST /contacts is not a replay. Reusing one with a different body is
409 idempotency_key_reused, and details names the resource the key already belongs to. A
malformed one is 400 idempotency_key_invalid.
Pagination
Every list is a cursor page:limit (1 to 100, default 50) and cursor. Pass back the nextCursor you were given, verbatim
and unparsed; when it is absent you have reached the end. There is no offset and no total count
anywhere, and a cursor that does not belong to the query it is sent to is 400 invalid_cursor.
Aggregates are their own route rather than a field on page one —
GET /dapi/v1/transactions/summary takes the same filters as GET /dapi/v1/transactions.
Status codes
There is no
204: every response has a body. Location and pollUrl are paths from the host root
and already include the /dapi/v1 prefix. One of them names no resource: a client’s pollUrl is
literally /dapi/v1/client, so poll it with the cl_… you were just given in X-Hevn-Account —
the URL alone identifies nothing.
Errors
One envelope, for every refusal, on every route:code is a stable lowercase slug — branch on it. message is written for a human reading a log and
may be reworded at any time. details is optional, camelCased, and carries the fields that make the
refusal actionable: blockers on a rail, fields on a validation failure, transactionHash on a
payment that already went through, requestId on a 5xx — quote that one to support.
Field-level failures collapse into one shape, so you can render them next to your form inputs:
Next: Signing
Prepare, check, sign, confirm — the one mechanism behind every payment.