Signature verification
A webhook endpoint is a public URL, so anything that finds it can POST to it. Abyssale signs every delivery with a secret only your workspace and Abyssale hold, which lets your receiver prove a payload came from us and was not modified in transit.
Signing is opt-in, and the secret coming into existence is what turns it on. A workspace that has never called a /signing-secret endpoint keeps receiving unsigned deliveries, exactly as before. GET is the normal way to create it, but rotate and revoke mint one too if none exists — so either of those also switches signing on for the whole workspace.
Get your signing secret
The first call creates the secret; every later call returns the same value. One secret covers the whole workspace — every event, and every delivery whether the receiver was subscribed in the dashboard or requested per job with a callback_url.
Sample request
curl -H "x-api-key: {YOUR-API-KEY}" https://api.abyssale.com/signing-secretResponse
{
"secret": "whsec_2f1a8c4e6b9d0a7c3e5f8b1d4a6c9e2f0b3d5a7c1e4f6b8d0a2c5e7f9b1d3a5c",
"created_at_ts": 1755561234,
"rotated_at_ts": null,
"previous_secret_expires_at_ts": null
}| Field | Type | Description |
|---|---|---|
secret | string | The value to verify with. Prefixed whsec_ so it is recognisable if it turns up somewhere it should not. |
created_at_ts | integer | Unix second the secret was first issued. |
rotated_at_ts | integer | null | Unix second of the most recent rotation, or null if never rotated. |
previous_secret_expires_at_ts | integer | null | Unix second the previous secret stops being honoured, while a rotation overlap is in progress. null otherwise. |
This is not your API key
They are different credentials, in opposite directions. Your API key authorises your calls to Abyssale and can spend generation credits. The signing secret only proves our delivery to you — it grants no access to anything. Never verify with the API key, and never put the signing secret in a request header.
Store it like a password: an environment variable or a secret manager, not in your repository.
The signature header
X-Abyssale-Signature: t=1755561234,v1=6d8146295ff97c0c7f10941e1cf9be4074e9f813f3727f7168336412398a1d4bA comma-separated list of tagged values, so it can gain a value without breaking a parser that ignores tags it does not know.
| Tag | Meaning |
|---|---|
t | Unix second we signed at. |
v1 | HMAC-SHA256(secret, "v1:webhook:" + t + "." + raw_body), hex-encoded. |
v1 names the signature scheme, not a key: it is version 1 of the algorithm. There can be more than one v1 in a single header — see Rotating — and both are computed the same way, differing only in which secret was used.
To verify, rebuild the signed string, hash it with your secret, and compare the result against every v1 present.
import { verifyWebhookSignature } from '@abyssale/sdk/webhooks';
// The subpath import needs no API key, so a receiver-only process holds no credential
// that can spend credits.
const ok = verifyWebhookSignature({
body: rawBody, // Buffer or string, exactly as received
header: req.headers['x-abyssale-signature'],
secret: process.env.ABYSSALE_SIGNING_SECRET,
});If you are not using an SDK, the whole check is a dozen lines:
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
signed = f"v1:webhook:{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# `isascii()` first: compare_digest raises on a non-ASCII str, and the header is untrusted.
return any(v.isascii() and hmac.compare_digest(expected, v) for k, v in parts if k == "v1")Five things that will silently break your verification
- Hash the raw body, never re-serialized JSON. Parsing and re-encoding reorders keys and changes spacing, and the signature stops matching. Use
request.get_data()in Flask,await request.body()in FastAPI,express.raw({ type: "application/json" })in Express, and keep the bytes as received. - There can be more than one
v1. During a rotation we sign with both the new and the old secret and send both hashes. Parsing the header into a dictionary keeps only one of them, so your handler works for months and then fails the first time you rotate. Check everyv1. tchanges on every retry; the delivery id does not. Deduplicate onX-Abyssale-Delivery-Id. Usetonly to reject stale deliveries, with a tolerance of a few minutes.- Compare in constant time (
hmac.compare_digest,crypto.timingSafeEqual) rather than with==. - Reject a malformed header, never raise on one. Anyone who can reach your webhook URL can send whatever they like in
X-Abyssale-Signature, so every path through your verifier has to return false rather than throw — a forged header that produces a 500 is an availability problem, and we will then retry it. The constant-time helpers are themselves a trap here:hmac.compare_digestraises on a string containing a non-ASCII character, andcrypto.timingSafeEqualthrows when the two buffers differ in length. Guard both before comparing, as the snippets above do.
Deduplicating deliveries
Every delivery carries X-Abyssale-Delivery-Id, 64 lowercase hex characters. It is present whether or not the delivery is signed, and it does not change between attempts: a delivery that exhausts the retry ladder arrives six times with the same id, while the signature's t is new each time. Store the ids you have processed and drop a repeat.
The id identifies a delivery, not an event. If one event is fanned out to several subscribed URLs, each subscription gets its own id — unique within one receiver, which is all deduplication needs, but not a value two of your endpoints can correlate on. Use the payload's own ids (generation_request_id, export_id) for that.
What a valid signature does and does not tell you
It proves the delivery came from Abyssale and was not modified. It does not make the payload authoritative about the current state of anything — a retry can arrive hours after the event, with the body rebuilt at send time. Keep treating a webhook as "something is ready, go look", and read the API when the answer matters.
Rotating the secret
Issues a new secret and keeps the previous one valid for 24 hours. During that window every delivery carries two v1 hashes, one per secret, so you can deploy the new value whenever you like without dropping a delivery. Keep accepting the old secret for a few minutes after you switch — retries already in flight were signed earlier.
Sample rotate request
curl -X POST -H "x-api-key: {YOUR-API-KEY}" https://api.abyssale.com/signing-secret/rotateRotating twice in a row is refused
A second rotate while the previous secret is still inside its 24-hour window would demote the secret the first rotate minted and drop the one your receiver is still verifying with — the outage the window exists to prevent, and a double-click away. You get 409 previous_secret_still_active, and nothing changes.
Three ways forward: wait for the window to close (previous_secret_expires_at_ts from the first response tells you when), POST /signing-secret/revoke to end the overlap now and rotate immediately after, or repeat the call with ?force=true if you really do mean to rotate twice and accept that anything signed with the oldest secret stops verifying within a minute.
| Parameter | Type | Required | Description |
|---|---|---|---|
force | boolean | No | Rotate even though the previous secret is still valid, revoking it. Only needed to override the 409 above; a first rotate never needs it. |
If your secret leaks
Rotate, deploy the new secret, and stop accepting the old one. That takes effect the moment you deploy: you are the verifier, so nothing needs to happen on our side for a forged request to start failing.
Drops the previous secret on our side, ending the overlap early. It leaves the current secret untouched, and unlike a rotate it is never refused — a grace period is precisely what you do not want when a key has leaked.
Signing picks the change up within 60 seconds, so a delivery already in flight may still carry a signature from the revoked secret for up to a minute. That is why the paragraph above matters: stop accepting the old secret in your verifier, and the leak is closed the moment you deploy — do not wait on this call.
curl -X POST -H "x-api-key: {YOUR-API-KEY}" https://api.abyssale.com/signing-secret/revoke