Ir al contenido

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.

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.
Ventana de terminal
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.

Before Relvo marks your endpoint active, it sends this to the URL you registered:

POST <your endpoint_url>
User-Agent: Relvo-Webhooks/1.0
Content-Type: application/json
Relvo-Verification: <challenge>
Relvo-Timestamp: <unix seconds>
Relvo-Webhook-Version: v1
Relvo-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:

  1. Recognize type == "relvo.webhook.verify" instead of routing it into your normal event handling.
  2. Respond 2xx.
  3. Echo the challenge back, either as a Relvo-Verification response 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 below

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

Once your endpoint is ready to answer the handshake:

Ventana de terminal
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.

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:

  1. Read the raw request body bytes before any JSON parsing. Re-serializing the parsed JSON changes the bytes and breaks the signature.
  2. Build the signed message: <Relvo-Timestamp> + . + <raw body>.
  3. Compute HMAC-SHA256(key, message) and hex-encode it. The key is the signing_secret string exactly as returned at creation, as UTF-8 bytes: there is no prefix to strip and nothing to base64-decode.
  4. Compare v1=<your hex> with Relvo-Signature using a constant-time comparison.
  5. Reject deliveries whose Relvo-Timestamp is 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.

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.

Update which event types an endpoint receives without recreating it:

Ventana de terminal
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.

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.