Skip to content
Oclavex is in private preview, available to selected users. Public signup is blocked. Request access at [email protected].

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.

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.

  • 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).
every message signed with the subscription secret Oclavex signs every delivery, delivers at-least-once retries with backoff, keeps what fails for requeue Your endpoint verify the signature, return 2xx fast dedupe on webhook-id, process async 1 · one-time registration challenge 2 · 2xx + signed echo → Active 3 · signed event delivery 4 · 2xx — or retried with backoff

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:

Terminal window
ngrok http 5001 # or: cloudflared tunnel --url http://localhost:5001

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.StandardWebhooks
using 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 standardwebhooks
const { Webhook } = require("standardwebhooks");
new Webhook(secret).verify(body, headers); // throws if the delivery doesn't verify
# Python — pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
Webhook(secret).verify(body, headers) # raises if the delivery doesn't verify

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

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-signature carries one or more, separated by spaces; accept the delivery if any one matches.
  • webhook-timestamp is 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))));
}
// Node
const 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)));
};

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:

  1. Return 2xx with a JSON body echoing the challenge: {"challenge": "<data.challenge>"}.
  2. Carry a webhook-signature response 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’s sign produces 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();
});

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

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