ROKIConnect

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 (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.
  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.

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.