Webhooks
Webhook endpoints let your application receive Relvo domain events — invoice issued, payment received, contract changes, and more — as HTTP callbacks, instead of polling. Registering one is a two-step process: create the endpoint, then prove you control the URL.
Endpoint lifecycle
Sección titulada «Endpoint lifecycle»pending_verification → active → disabled / deleted| Status | Meaning |
|---|---|
pending_verification |
Registered, but ownership hasn’t been confirmed yet. No events are delivered here. |
active |
Verified and receiving deliveries for its subscribed event types. |
disabled |
Temporarily stopped, by you or by Relvo; can be restored. |
deleted |
Permanently removed. |
1. Register an endpoint
Sección titulada «1. Register an endpoint»curl -X POST "https://app.relvoerp.com/api/v1/public/webhook-endpoints" \ -H "Authorization: Bearer rk_live_xxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "endpoint_url": "https://example.com/relvo/webhooks", "subscriptions": [ { "event_type": "invoice.issued", "schema_version": 1 }, { "event_type": "payment.received", "schema_version": 1 } ] }'{ "id": "018f1234-5678-7abc-def0-1234567890ab", "status": "pending_verification", "signing_secret": "kP3x_9vN2-qT7...", "verification_challenge": "AbC123-xyz..."}Two values in the response are shown only once — store them immediately:
signing_secret— the key for verifying every future delivery to this endpoint; see Verifying delivery signatures. It’s an opaque URL-safe string with no prefix, and it’s never shown again. The public API can’t rotate it: if it’s lost or compromised, delete the endpoint and register a new one, or ask your Relvo contact to rotate it.verification_challenge— the value your endpoint must hand back to prove ownership. It expires 15 minutes after creation.
2. Implement the verification handshake
Sección titulada «2. Implement the verification handshake»Before Relvo marks your endpoint active, it sends this to the URL you registered:
POST <your endpoint_url>User-Agent: Relvo-Webhooks/1.0Content-Type: application/jsonRelvo-Verification: <challenge>Relvo-Timestamp: <unix seconds>Relvo-Webhook-Version: v1Relvo-Signature: v1=<hex>
{"type":"relvo.webhook.verify","challenge":"<challenge>"}The challenge is sent both ways — header and body — so you can read it from whichever is easiest. The request is signed exactly like an event delivery, with the same signing_secret and the same Relvo-Timestamp / Relvo-Signature headers (see Verifying delivery signatures), so a handler that checks signatures on every request can keep doing so. If your signature check would reject it, it would reject every real delivery too, and /verify tells you now instead of after go-live. Your endpoint needs to:
- Recognize
type == "relvo.webhook.verify"instead of routing it into your normal event handling. - Respond
2xx. - Echo the challenge back, either as a
Relvo-Verificationresponse header or a{ "challenge": "..." }JSON body. One of the two is enough.
const payload = await request.json();
if (payload.type === "relvo.webhook.verify") { return json({ challenge: payload.challenge }, { status: 200 });}
// ... handle your normal event types belowIf your handler only matches known business event types and ignores everything else, it will 2xx the verification request without echoing anything — and verification will keep failing. Match relvo.webhook.verify explicitly.
3. Trigger verification
Sección titulada «3. Trigger verification»Once your endpoint is ready to answer the handshake:
curl -X POST "https://app.relvoerp.com/api/v1/public/webhook-endpoints/{id}/verify" \ -H "Authorization: Bearer rk_live_xxxxxxxxxxxxxxxxxxxx"| Result | Meaning |
|---|---|
204 No Content |
Verified — the endpoint transitions to active and starts receiving real deliveries. |
409 Conflict |
Your endpoint didn’t respond with 2xx, or didn’t echo the challenge back correctly. Fix your handler and call /verify again — no need for a fresh challenge unless more than 15 minutes have passed since creation. |
404 Not Found |
Wrong ID, wrong organization, or the endpoint was already deleted. |
/verify is idempotent per Idempotency-Key header, so retries are safe.
Verifying delivery signatures
Sección titulada «Verifying delivery signatures»Every event delivery is a POST carrying these headers (the verification handshake carries the same signature headers, without the two id headers):
| Header | Value |
|---|---|
Relvo-Signature |
v1=<hex> — lowercase hex HMAC-SHA256, 64 characters |
Relvo-Timestamp |
Unix seconds at which this attempt was signed |
Relvo-Event-Id |
The event’s id; stable across retries and redeliveries, so use it to deduplicate |
Relvo-Delivery-Id |
This delivery’s id |
Relvo-Webhook-Version |
v1 |
To verify:
- Read the raw request body bytes before any JSON parsing. Re-serializing the parsed JSON changes the bytes and breaks the signature.
- Build the signed message:
<Relvo-Timestamp>+.+<raw body>. - Compute
HMAC-SHA256(key, message)and hex-encode it. The key is thesigning_secretstring exactly as returned at creation, as UTF-8 bytes: there is no prefix to strip and nothing to base64-decode. - Compare
v1=<your hex>withRelvo-Signatureusing a constant-time comparison. - Reject deliveries whose
Relvo-Timestampis more than 5 minutes from your clock, to block replays.
This is not the Svix / Standard Webhooks scheme, even though the header names look similar: there is no webhook-id in the signed message, the key is not base64, and the signature is hex, not base64.
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidRelvoSignature(rawBody: Buffer, headers: Headers, secret: string): boolean { const timestamp = headers.get("Relvo-Timestamp") ?? ""; const received = headers.get("Relvo-Signature") ?? ""; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = "v1=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"); return received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));}Test vector. Check your implementation against this before you go live (skip the timestamp freshness check for this test):
| Input | Value |
|---|---|
signing_secret |
Zm9vYmFyLWV4YW1wbGUtc2VjcmV0LW5vdC1yZWFs |
Relvo-Timestamp |
1767225600 |
| Raw body | {"id":"evt_example","type":"invoice.created"} |
Expected Relvo-Signature |
v1=85aa7b5a61c88ce3938c9f6afb700df33af81758431eaef9258ab3d0d7b7967e |
The verification handshake is signed with the same scheme, so a handler that checks signatures on every request exercises its signature code during verification. A handler that skips the check for relvo.webhook.verify does not, and can still become active while rejecting every real delivery — run the test vector first.
Responses, retries and failed deliveries
Sección titulada «Responses, retries and failed deliveries»| Your response | What Relvo does |
|---|---|
2xx |
Delivered. |
408, 429, 5xx, timeout or connection error |
Retried with backoff, up to 12 attempts in total. Retry-After is honoured. |
Any other status, including 401/403 |
Failed permanently on that attempt. Not retried. |
A delivery that fails permanently is kept, not discarded, and Relvo can redeliver it once your endpoint is fixed; ask your Relvo contact. Relvo does not disable an endpoint because its deliveries fail, so a misconfigured endpoint keeps failing until it is fixed. Events published while an endpoint is disabled are never queued for it.
Managing subscriptions
Sección titulada «Managing subscriptions»Update which event types an endpoint receives without recreating it:
curl -X PUT "https://app.relvoerp.com/api/v1/public/webhook-endpoints/{id}" \ -H "Authorization: Bearer rk_live_xxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "subscriptions": [ { "event_type": "invoice.issued", "schema_version": 1 }, { "event_type": "invoice.voided", "schema_version": 1 } ] }'This replaces the full subscription list — include every event type you still want, not just the ones you’re adding.
Disabling and deleting
Sección titulada «Disabling and deleting»DELETE /public/webhook-endpoints/{id} removes an endpoint permanently. Disabled or deleted endpoints stop receiving deliveries immediately, and state-changing operations on them (like re-verifying a deleted endpoint) return 409 Conflict, per the error conventions.