Docs
ENFR
Dashboard
Webhooks

Webhooks

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

Operators and card networks answer in seconds or minutes, never instantly. Instead of polling, give Monerails 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.monerails.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, rotate it. 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>, with a second v1 while a secret rotation overlaps
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 every v1 from Monerail-Signature. There is usually one; there are two while a secret rotation overlaps.
  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 each v1 in constant time and accept the event if one matches. Refuse events whose t is more than 5 minutes away from your clock: a captured request cannot be replayed later.

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 Monerails 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"

Rotate the secret

Roll the signing secret of an endpoint with POST /v1/webhook_endpoints/{id}/rotate_secret (Owner or Developer). The response contains the new secret: store it, it is not shown again.

The old secret keeps working for overlap_minutes (24 hours by default, 0 to 10080, that is 7 days). During that time, every delivery is signed with both secrets: Monerail-Signature: t=…,v1=<new>,v1=<old>. Deploy the new secret, then let the overlap end. Use overlap_minutes: 0 to revoke the old secret immediately, for example after a leak. One rotation per minute at most per endpoint.

bash
curl https://api.monerails.com/v1/webhook_endpoints/we_…/rotate_secret \
  -H "Authorization: Bearer $MONERAIL_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"overlap_minutes": 1440}'

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, Monerails 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.