Roamerwave sends an HTTPS POST to your endpoint when something happens in your workspace. Every delivery is signed, so you can be sure it came from Roamerwave and has not been altered or replayed.
Set up an endpoint
A workspace owner adds endpoints in the Developers section of the workspace. Give it an https URL on your own server. The workspace then shows the signing secret, beginning whsec_, once. Store it as a secret. You can send a test event from the same page at any time.
Events
| Event | Sent when |
|---|---|
invoice.created | An invoice is created in your workspace. |
payment.succeeded | The provider confirms a payment. |
webhook.test | You ask for a test event for one endpoint. |
Payment success, reconciliation and settlement are separate facts and are never merged into one event. payment.succeeded means the provider confirmed the customer's payment. It does not mean the payment is reconciled or settled.
There is no payment.succeeded event for a payment whose result is unknown. An unknown result stays unknown until the provider's records resolve it, and only a confirmation produces the event. Do not treat the absence of an event as a failure. See Take a payment.
The delivery
The body is JSON. Events carry identifiers only, not amounts or contact details. To get current state, read it from the API with the id in data.object.
{
"id": "evt_…",
"type": "payment.succeeded",
"schemaVersion": "1",
"workspaceId": "ws_…",
"resourceId": "payatt_…",
"occurredAt": "2026-10-21T09:14:03+00:00",
"data": { "object": { "id": "payatt_…", "type": "payment_attempt" } }
}
Each request carries these headers:
RoamerWave-Event-Id: evt_…
RoamerWave-Event-Type: payment.succeeded
RoamerWave-Delivery-Id: whd_…
RoamerWave-Signature: t=1792574043,kid=wsk_…,v1=3b0c…
The signature
RoamerWave-Signature is a comma-separated list: t is the Unix time in seconds the delivery was signed, and each kid and v1 pair is one signing secret and its signature. Each v1 is the lowercase hex of an HMAC-SHA256, keyed with your signing secret (whsec_…, used as UTF-8 text), over the string t, a full stop, then the exact request body bytes:
v1 = hex( HMAC-SHA256( key = secret, message = "<t>." + raw body ) )
During a secret rotation the header carries one pair for each active secret, newest first. Accept the delivery if any v1 matches the secret you hold.
To verify:
- Read the raw request body bytes before parsing the JSON. Re-serialising it changes the bytes and breaks the check.
- Split the header on commas. Take
tand everyv1. - Reject the delivery if
tis more than 300 seconds from your current time. - Compute the HMAC and compare it with each
v1using a constant-time comparison. - Ignore a delivery whose
RoamerWave-Event-Idyou have already processed. - Answer with any
2xxstatus quickly and do the work afterwards.
Node.js
import crypto from "node:crypto";
export function verify(rawBody, header, secret, now = Date.now() / 1000) {
const parts = header.split(",").map((part) => part.split("="));
const t = Number(parts.find(([name]) => name === "t")?.[1]);
if (!Number.isInteger(t) || Math.abs(now - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest();
return parts.some(
([name, value]) =>
name === "v1" &&
/^[0-9a-f]{64}$/.test(value) &&
crypto.timingSafeEqual(Buffer.from(value, "hex"), expected),
);
}
Python
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
timestamp = None
candidates = []
for item in header.split(","):
name, _, value = item.strip().partition("=")
if name == "t" and value.isdigit():
timestamp = int(value)
elif name == "v1":
candidates.append(value)
if timestamp is None or abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, value) for value in candidates)
Retries and repeats
Delivery is at least once. If your endpoint does not answer with a 2xx status, Roamerwave retries with increasing delays for about two days, so the same event can arrive more than once. The RoamerWave-Event-Id is the same on every retry and replay: use it to make your handler idempotent. You can replay a delivery from the workspace.