Webhooks
They are the authoritative confirmation of a charge. Without them, an integration depends on the customer returning to the site - which does not always happen.
14.1 Registration (a manual merchant step)
At https://aura.roki.systems/merchant/connect/webhooks:
- Pick the environment with the Sandbox (prueba) / En vivo (produccion) toggle - it must match
the
sk_key the integration uses. This is the most common mistake: registering the endpoint in production while developing with a test key, and receiving nothing. - Paste the public HTTPS URL of your endpoint.
- Guardar endpoint.
- Copy the signing secret the portal shows and store it in your project configuration.
Separate URLs can be registered for sandbox and production, each with its own secret.
The portal shows "Entregas recientes" (the last 10 delivery attempts) - the diagnostic tool when events are not arriving.
14.2 Events
| Event | When |
|---|---|
payment.approved |
The customer paid successfully. Observed within ~2 seconds of payment. |
payment.failed |
Card declined or payment error. |
payment.expired |
The link expired before checkout completed. Observed ~5 minutes after expires_at, not at the instant: expiry is swept on a schedule. |
payment.voided |
An approved payment was voided. |
payment.refunded |
Full refund. |
payment.partially_refunded |
Partial refund. |
payment_method.saved |
The customer ticked "save my card"; carries the opaque pm_* (23.2). |
The last three also arrive when the action is performed from the portal.
14.3 Event structure
The exact shape of a delivery, headers included.
Headers
| Header | Example | What it is for |
|---|---|---|
ROKI-Signature |
t=1786747980,v1=f90f1c... |
Verify it. See 14.4. |
ROKI-Webhook-Event-Id |
9a7a9698-e270-422a-ba8f-365e944248aa |
The event id, also in the header. Deduplicate on this without parsing the body. |
ROKI-Webhook-Event-Type |
payment.approved |
Route without parsing the body. |
User-Agent |
GuzzleHttp/7 |
ROKI's own client. Do not filter on it. |
Body
{
"id": "9a7a9698-e270-422a-ba8f-365e944248aa",
"type": "payment.approved",
"created_at": "2026-08-14 16:52:58",
"data": {
"id": 1049,
"status": "paid",
"amount": 10,
"subtotal": 10,
"total": 10,
"sales_tax_amount": 0,
"service_fee_amount": 0,
"currency": "340",
"currency_iso": "HNL",
"external_reference": "demo-hosted-mstjlm2w",
"name": "Prueba modo 1 - guardar tarjeta",
"description": null,
"metadata": [],
"customer": { "name": null, "email": "demo@roki.la", "phone": null, "identity_number": "0801199000000" },
"lock_customer_fields": false,
"reusable": false,
"checkout_url": "https://aura.roki.systems/pay/link/6ixekbmlwf8e",
"transaction_id": "91b73300-38b3-48eb-b9d0-72941896b26e",
"refunded_amount": 0,
"refund_status": "none",
"expires_at": "2026-08-14 17:22:35",
"created_at": "2026-08-14 16:52:36",
"paid_at": "2026-08-14 16:52:56"
}
}
Three details that break integrations:
- The event
idis a plain UUID, with no prefix, in both the body and theROKI-Webhook-Event-Idheader. Do not match on a prefix. data.transaction_idis a UUID string, and isnulluntil the payment is charged. Storing it in an integer column truncates it, which is what makes void and refund return a routing 404 later (13.6). See the schemas in 19.2.datacarries the whole payment object, identical in shape toGET /payments/{id}- not the subset the example suggests.metadataarrives as[]when empty, not{}.
Refund events additionally include refund_amount, refunded_at and refund_reason inside
data; refunded_amount and refund_status are present on every paid payment.
14.4 Signature verification (mandatory)
Header ROKI-Signature: t={timestamp},v1={hmac_sha256_hex}.
The HMAC is computed over timestamp + "." + raw_body using the signing secret:
expected = HMAC-SHA256(timestamp + "." + exact_raw_body, signing_secret)
valid = constant_time_compare(expected, v1)
Three rules that break verification when ignored:
- Use the raw body, byte for byte. If your framework parses the JSON and you re-serialize it, the signature will never match: spacing, key order or escaping change.
- Constant-time comparison (
hash_equals,crypto.timingSafeEqual,hmac.compare_digest). Never==. - Invalid signature -> respond 400. Valid -> respond 200 fast and process asynchronously; do not do heavy work before responding.
14.5 Idempotent processing
The same event can arrive more than once. Store the event id and discard repeats, or make
processing naturally idempotent (marking an already-paid order as paid must not charge twice or send
two emails).
The cheapest deduplication is on the ROKI-Webhook-Event-Id header: it is the same id that appears
in the body, so a repeat can be discarded before the payload is even parsed.
ROKI's retry policy is undocumented. Assume retries can happen and that delivery order is not guaranteed.
14.6 Fallback: direct lookup
Webhooks can be lost (server down, deploy, network). Implement a fallback: if an order is still
pending after X minutes, call GET /payments/{id}. This matters more than usual here because there
is no list endpoint for bulk reconciliation.
