Receiving webhooks
A receiver does three things: verify the signature, answer 2xx fast, and deduplicate. The wire contract — envelope, headers, event catalog — is in Webhook events; this page is the receiving side.
Before you have a receiver
Section titled “Before you have a receiver”You don’t need an endpoint to start. Register a capture subscription instead: it has no URL and no signing secret, nothing is sent anywhere, and it goes Active immediately — there is no registration challenge, because there is nothing to prove. Every matching event is recorded so you can read the exact envelope your endpoint would have received, which is the fastest way to settle on event types and filters before you write a line of code.
Captured deliveries are kept for 7 days, or the 1,000 most recent per capture subscription — whichever comes first. Switch to a real subscription once you know what you want to receive.
Endpoint requirements
Section titled “Endpoint requirements”- HTTPS, reachable from the internet.
- Respond 2xx fast — verify, persist, return; process asynchronously. A slow handler gets timed out and retried.
- Hold the subscription’s signing secret server-side. It is a Standard Webhooks
secret,
whsec_followed by base64: the portal generates one when you create the subscription and shows it once, and a secret you supply through the API must have the same form (24 to 64 random bytes).
Local development
Section titled “Local development”https://localhost:5001 won’t be accepted — loopback, private and link-local
addresses, internal host names and plain http are all refused with
400 subscription.invalid_url (error 4033), because
the platform must not be aimed at anything but a public endpoint. Put a tunnel in
front of your local receiver and register the tunnel’s public URL:
ngrok http 5001 # or: cloudflared tunnel --url http://localhost:5001Verify every delivery
Section titled “Verify every delivery”Deliveries are signed exactly as Standard Webhooks
specifies, so any Standard Webhooks library verifies them. Hand it the subscription’s
signing secret as the portal showed it (whsec_…), the headers, and the body as raw
text — read it before any JSON parsing, because re-serialized JSON won’t match.
// dotnet add package StandardWebhooks.StandardWebhooksusing System.Net;using Microsoft.AspNetCore.Http;using StandardWebhooks;
static void Verify(string secret, HttpRequest request, string body){ var headers = new WebHeaderCollection(); foreach (var (name, value) in request.Headers) headers.Add(name, value.ToString());
new Webhook(secret).Verify(body, headers); // throws WebhookVerificationException}// Node — npm install standardwebhooksconst { Webhook } = require("standardwebhooks");
new Webhook(secret).verify(body, headers); // throws if the delivery doesn't verify# Python — pip install standardwebhooksfrom standardwebhooks.webhooks import Webhook
Webhook(secret).verify(body, headers) # raises if the delivery doesn't verifyThe library checks everything a receiver has to: the signature, compared in constant
time; any one of several space-separated signatures, which is how a
secret rotation arrives; and a webhook-timestamp within five
minutes of now, which shuts down replays.
Without a library
Section titled “Without a library”The scheme is small enough to write yourself:
- The key is the part of the secret after
whsec_, base64-decoded — not the secret’s text. - The signed content is
{webhook-id}.{webhook-timestamp}.{raw body}. - The signature is
v1,followed by the base64 HMAC-SHA256 of that content.webhook-signaturecarries one or more, separated by spaces; accept the delivery if any one matches. webhook-timestampis the send time in Unix seconds. Reject a delivery whose timestamp is more than a few minutes from your clock.
using System;using System.Linq;using System.Security.Cryptography;using System.Text;
static class WebhookSignature{ public static string Sign(string secret, string id, string timestamp, string body) => "v1," + Convert.ToBase64String(HMACSHA256.HashData( Convert.FromBase64String(secret["whsec_".Length..]), Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}")));
public static bool Verify(string secret, string id, string timestamp, string body, string header) => long.TryParse(timestamp, out var sent) && Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - sent) <= 5 * 60 && header.Split(' ').Any(candidate => CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(candidate), Encoding.UTF8.GetBytes(Sign(secret, id, timestamp, body))));}// Nodeconst crypto = require("crypto");
const sign = (secret, id, timestamp, body) => "v1," + crypto .createHmac("sha256", Buffer.from(secret.slice("whsec_".length), "base64")) .update(`${id}.${timestamp}.${body}`) .digest("base64");
const verify = (secret, id, timestamp, body, header) => { const sent = Number(timestamp); if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 5 * 60) return false; const expected = sign(secret, id, timestamp, body); return header.split(" ").some(candidate => candidate.length === expected.length && crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)));};Answer the registration challenge
Section titled “Answer the registration challenge”A new subscription starts Pending verification — creating it does not, by
itself, send anything to your URL. Verify in the portal (or the integrator API’s
POST /api/v1/webhooks/integrator/subscriptions/{id}/verify) makes Oclavex POST a
one-time Webhooks.Registration.Challenge event to your URL; the subscription
becomes Active only once your endpoint proves it holds the secret. Changing the
subscription’s URL later puts it back to Pending verification until you verify
the new URL. Your response must:
- Return 2xx with a JSON body echoing the challenge:
{"challenge": "<data.challenge>"}. - Carry a
webhook-signatureresponse header — the same signature scheme, over{webhook-id}.{webhook-timestamp}.{your response body}using the request’s id and timestamp headers. A Standard Webhooks library’ssignproduces it.
(Subscriptions created through the integrator API verify with a lighter check — just the 2xx, no signed echo — for endpoints that can’t customize their response, like a Zapier Catch Hook.)
using System.Net;using StandardWebhooks;using StandardWebhooks.Exceptions;
app.MapPost("/webhooks/oclavex", async (HttpContext ctx) =>{ var body = await new StreamReader(ctx.Request.Body).ReadToEndAsync(); var headers = new WebHeaderCollection(); foreach (var (name, value) in ctx.Request.Headers) headers.Add(name, value.ToString());
var webhook = new Webhook(secret); try { webhook.Verify(body, headers); } catch (WebhookVerificationException) { return Results.Unauthorized(); }
var envelope = JsonDocument.Parse(body).RootElement; if (envelope.GetProperty("type").GetString() == "Webhooks.Registration.Challenge") { var reply = JsonSerializer.Serialize(new { challenge = envelope.GetProperty("data").GetProperty("challenge").GetString() }); var sentAt = DateTimeOffset.FromUnixTimeSeconds(long.Parse(headers["webhook-timestamp"]!)); ctx.Response.Headers["webhook-signature"] = webhook.Sign(headers["webhook-id"]!, sentAt, reply); return Results.Text(reply, "application/json"); }
await queue.EnqueueAsync(envelope, ctx.RequestAborted); // process async return Results.Ok();});Handle retries and duplicates
Section titled “Handle retries and duplicates”Delivery is at-least-once: a timeout after successful processing means the
same event arrives again. Record webhook-id (unique per event) and skip ids
you’ve already processed. Failed deliveries retry with exponential backoff;
what ultimately fails is kept for inspection and can be requeued from the portal.
Don’t assume ordering — two related events can arrive out of order after a retry. Treat each event as a signal to fetch current state when order matters.
Rotate the secret
Section titled “Rotate the secret”Rotate from the portal (Settings → Webhooks), or — for a subscription an
integration credential created — via
POST /api/v1/webhooks/integrator/subscriptions/{id}/secret. During the overlap
window Oclavex signs with both secrets (space-separated in
webhook-signature), so a receiver that checks all candidates — as the
Standard Webhooks libraries and the snippets above do — rolls keys with zero
dropped events.
Call back with the delivery token
Section titled “Call back with the delivery token”If the subscription requests callback scopes, each delivery carries a
short-lived JWT in webhook-callback-token. Use it as a bearer token to call
back into the API with exactly those scopes. For example: on
MeteredUsage.OverageStatusChanged, read the balance — no stored credential, no
OAuth round-trip.
The scopes on offer cover the catalog, licenses, sessions, usage, quotas,
credit balances, metrics and ratings. Account administration, sign-up, privacy
and connector settings are not among them, and neither are delete verbs or
wildcards — see Calling back.
Test it
Section titled “Test it”Test-fire in the portal lets you pick the event type, preview the exact payload, and send a sample event through the full pipeline — signature, headers, retry behavior — so you can validate the receiver before real traffic hits it.
The payload matches the documented schema for the chosen event type and always
carries "_test": true inside data, covered by the signature like the rest of
the body. Gate on _test: never apply a test event to real state — the sample
payloads reference fixed placeholder ids, not your data.