Docs
ENFR
Dashboard
Webhooks

Webhooks

Receive events on your server and verify that they come from Monerail.

Operators and card networks answer in seconds or minutes, never instantly. Instead of polling, give Monerail an HTTPS URL: it sends an event there each time something happens.

Set up an endpoint

In the dashboard under Developers › Webhooks, choose a preset: All events, Payments (collections, payouts, settlements) or Cards (cards and cardholders). For a finer selection, such as collection.* only, use the API:

bash
curl https://api.monerail.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $MONERAIL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/monerail", "events": ["collection.*", "payout.*"]}'

The URL must be HTTPS, with a domain name (IP addresses are refused) and without user:password@. Otherwise the request fails with 400 url_invalid.

The 201 response contains the endpoint’s signing secret (whsec_…). Store it now: it is never shown again, not even if you replay the request with the same Idempotency-Key. If you lose it, delete the endpoint and create a new one. Test and live endpoints are separate, each with its own secret; live endpoints need an approved business verification. Your business can have up to 10 endpoints, test and live together.

What you receive

A POST with a JSON body, the event:

JSON
{
  "id": "3ab70bfc-785d-45ce-99de-fd9ab67e5cd4",
  "object": "event",
  "type": "collection.succeeded",
  "livemode": false,
  "created_at": "2026-10-30T13:47:58.723Z",
  "data": {
    "object": {
      "id": "3b8f6c1e-2a47-4d1b-9e35-7c0a9d2f4e61",
      "object": "collection",
      "status": "succeeded",
      "amount": 5000,
      "fee": 125,
      "net": 4875,
      "currency": "XAF",
      "client_reference": "order_42"
    }
  }
}

data.object is the full object, in the same format as the API, in its state when the event is delivered. Webhook events shows a complete example for each family.

Header Content
Monerail-Signature t=<timestamp>,v1=<signature>
Monerail-Event-Id The event ID.
Monerail-Event-Type The event type.
Monerail-Delivery-Attempt 1 for the first attempt, then 2, 3…
User-Agent Monerail-Webhooks/1.0

Verify the signature

Anyone can send a request to your URL. Before trusting an event, check its signature:

  1. Read t (Unix time, in seconds) and v1 from Monerail-Signature.
  2. Compute HMAC-SHA256 of t + "." + raw body with your endpoint secret (the whole string, whsec_ included) as key, in lowercase hexadecimal.
  3. Compare it to v1 in constant time, and refuse events whose t is more than 5 minutes away from your clock.

Use the raw body, exactly as received: parsing then re-serialising the JSON changes the bytes and breaks the signature. The curl tab sends a correctly signed test event to your local server.

# Send a test event to your local endpoint, signed exactly as Monerail signs it
SECRET="whsec_..."   # the secret of the endpoint you are testing
BODY='{"id":"3ab70bfc-785d-45ce-99de-fd9ab67e5cd4","object":"event","type":"collection.succeeded","livemode":false,"created_at":"2026-10-30T13:47:09.807Z","data":{"object":{"id":"3b8f6c1e-2a47-4d1b-9e35-7c0a9d2f4e61","object":"collection","status":"succeeded"}}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

# replace the URL with your endpoint
curl http://localhost:3000/webhooks/monerail \
  -H "Content-Type: application/json" \
  -H "Monerail-Signature: t=$T,v1=$SIG" \
  -d "$BODY"

Deliveries and retries

  • Answer with a 2xx status within 10 seconds. Anything else, a timeout or a redirect counts as a failure (redirects are not followed).
  • After a failure, Monerail tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours: 8 attempts in all, over about 42 hours. After the last one, the delivery is marked failed.
  • If your domain resolves to a private, loopback, link-local or reserved address, the delivery fails with “destination not allowed” and is retried like any other failure.
  • Events usually leave within 15 seconds, but not always in order, and the same event can arrive more than once.
  • To send an event again, use Redeliver an event (up to 10 times per event per hour). The dashboard has no redeliver button.

Good practices

  • Answer first, process after. Put the event in a queue and return 200 right away.
  • Deduplicate with the event id: store the IDs you have processed and ignore repeats.
  • Trust the status, not the order. Base your logic on data.object.status, or fetch the object with the API when in doubt.
  • Catch up after an outage with List events.
  • Reconcile periodically with List transactions.

Event types

Typedata.objectDescription
collection.succeededcollectionA collection succeeded and the net amount was credited to your balance.
collection.failedcollectionA collection failed (customer declined, insufficient funds, wrong code…).
collection.expiredcollectionThe customer did not approve the collection in time.
payout.succeededpayoutA payout reached the recipient.
payout.failedpayoutA payout failed. The amount and the fee were returned to your balance.
settlement.succeededsettlementA settlement reached your settlement account.
settlement.failedsettlementA settlement failed. The amount was returned to your balance.
cardholder.createdcardholderA cardholder was created.
cardholder.kyc_updatedcardholderThe identity verification of a cardholder changed status. Read verification.status.
card.createdcardA card was requested.
card.issuedcardThe issuer issued the card. It is active.
card.issue_failedcardThe issuer could not issue the card. The initial funding was returned to your cards balance.
card.frozencardA card was frozen.
card.unfrozencardA card was unfrozen.
card.terminatedcardA card was terminated. Its remaining balance returned to your cards balance.
card.fundedtransactionA card was funded from the cards balance.
card.withdrawntransactionMoney was withdrawn from a card back to the cards balance.
card.transactiontransactionA card payment was made.
card.refundedtransactionA merchant refunded a card payment.
card.reversedtransactionA card payment was reversed before settlement.
card.authorization_declinedauthorizationA card payment attempt was declined. data.object is { object: "authorization", card, amount, currency, merchant, reason }, with the full card.
cards_balance.creditedtransactionYour cards balance was credited.

Subscribe to * for everything, or to a family with collection.*, payout.*, card.*… New event types can appear: ignore the ones you do not handle.