ROKIConnect
Connectors Start API SDKs Migrate MCP Portal ES

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:

  1. Pick the environment with the Sandbox (prueba) / En vivo (producción) 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.
  2. Paste the public HTTPS URL of your endpoint.
  3. Guardar endpoint.
  4. 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:

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:

  1. 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.
  2. Constant-time comparison (hash_equals, crypto.timingSafeEqual, hmac.compare_digest). Never ==.
  3. 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.

The retry policy, confirmed by the API team on 2026-08-25:

Timeout per attempt 15 seconds
Total attempts 5
Wait between attempts 1 minute, then 5 minutes, then 15 minutes, then 1 hour
After the fifth nothing more is sent
Event id across retries the same one, every time

Three consequences worth designing around:

  1. Answer inside 15 seconds. Do the signature check, write the event id, and return 200. Do the real work afterwards, out of the request. A handler that fulfils an order inline - sending mail, calling a warehouse - will blow the timeout under load, and ROKI will resend an event you already processed.
  2. The whole retry window is about 1 hour 21 minutes. After that, a delivery that never succeeded is gone for good, and the only thing that recovers it is your own polling. That is what makes the fallback in 14.6 mandatory rather than nice to have.
  3. The event id does not change between attempts, so deduplication by event id is exactly the right guard - a retry is indistinguishable from the original except for arriving later.

Delivery order is still not guaranteed. An event that took four retries arrives after events that succeeded on the first try, so a handler must not assume the last event received is the latest state. Section 14.4 covers deciding by event type rather than arrival order.

Rotating the signing secret: there is no overlap. The new secret replaces the old one immediately - the old one stops verifying the moment you rotate. Update your server first, or in the same movement; a gap between the two is a gap where every delivery fails its signature check and burns its five attempts. If you cannot deploy and rotate together, accept both secrets in your handler for the length of the deploy: verify against the new one, and fall back to the old one, then remove the fallback.

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 a redirect is not proof of payment. For bulk reconciliation, GET /payments takes a from/to date range.