GuidesWebhooks
Webhooks
Get an HTTPS request the moment something happens: an envelope is sent or completed, a recipient signs, a file changes in KeepAnything.
Adding an endpoint
Add endpoints in Settings › Developers, or with POST /v1/webhooks and a key with webhooks:manage. List the events you want in event_filter, or send ["*"] (or leave it out) for every event, including ones we add later.
- The URL must be
https://, up to 2,048 characters, with no user name or password, and reach a public address. Private, loopback and link-local addresses are refused, when you add it and on every delivery. - An endpoint takes the mode of the key that created it and only gets that mode’s events. Make one with a test key to receive events from test envelopes.
- File, folder and sharing events are always live — KeepAnything has no test mode.
- An account can have up to 20 endpoints, live and test together.
curl https://api.skanything.io/v1/webhooks \
-H "Authorization: Bearer $SA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/signanything",
"event_filter": ["envelope.completed", "recipient.signed"],
"description": "Lease system"
}'The response includes the endpoint’s signing secret (whsec_…). It’s shown only here and when you rotate it, so store it with your other secrets — you need it to verify every request.
What we send
Each event is a POST with a JSON body (Content-Type: application/json; charset=utf-8, User-Agent: SignAnything-Webhooks/1.0). data.object is the object as it was when the event happened, in the same shape the API returns.
| Field | Type | Description |
|---|---|---|
id | string | Event id, like evt_5b1e0c9a…. The same id on every retry — use it to drop duplicates. |
object | string | Always "event". |
type | string | One of the events below, like envelope.completed. |
created | string | When it happened: an ISO 8601 date-time in UTC. |
livemode | boolean | true for live events, false for test. |
api_version | string | "v1". |
test | boolean | Only on events sent with Send test: true, with sample data and sample ids. |
data.object | object | Envelope and recipient events: the envelope, with its recipients and documents. KeepAnything events: the file, folder, share, link or file request. |
data.recipient_id | string | Recipient events only: the recipient it’s about. |
Event catalogue
33 events. Open a row to see an example payload.
| Event | Sent when | Example |
|---|---|---|
| Signing 11 events | ||
envelope.sent | An envelope goes out | |
envelope.completed | Everyone has signed | |
envelope.declined | A recipient declines, so the envelope is declined | |
envelope.voided | The sender voids it | |
envelope.expired | It expires unsigned | |
recipient.sent | A recipient gets their turn (or a signing URL is made for them) | |
recipient.viewed | A recipient opens it | |
recipient.signed | A recipient signs | |
recipient.declined | A recipient declines | |
recipient.auth_failed | A recipient fails the identity check | |
recipient.reassigned | A signer passes their turn to someone else | |
| Test 1 event | ||
ping | You press Send test, or call POST /v1/webhooks/{id}/test | |
| Files KeepAnything · live only 8 events | ||
file.created | A file is added | |
file.version_added | A new version is ready (also when a new file has passed the virus check) | |
file.renamed | A file is renamed | |
file.moved | A file is moved | |
file.trashed | Moved to Trash | |
file.restored | Restored from Trash | |
file.deleted | Deleted for good | |
file.infected | The virus check finds a threat | |
| Folders KeepAnything · live only 6 events | ||
folder.created | A folder is added | |
folder.renamed | A folder is renamed | |
folder.moved | A folder is moved | |
folder.trashed | Moved to Trash | |
folder.restored | Restored from Trash | |
folder.deleted | Deleted for good | |
| Sharing KeepAnything · live only 7 events | ||
share.added | Someone is given access | |
share.changed | Someone’s access changes | |
share.removed | Someone’s access is removed | |
link.created | A share link is created | |
link.changed | A link’s settings change | |
link.disabled | A link is turned off | |
file_request.uploaded | Someone uploads to a file request | |
A new file sends file.created as soon as it arrives, then file.version_added once the virus check has passed, with the version’s sha256 and scan_status. A declined envelope sends both recipient.declined and envelope.declined.
Verifying signatures
Every request carries a SignAnything-Signature header. Check it before you trust the body.
SignAnything-Signature: t=1793816561,v1=5d41402abc4b2a76b9719d911017c592ae2d1e3f4c0d7b6a9e8f1c2b3a4d5e6f
- Split the header on commas.
tis the Unix time we signed it; eachv1is a signature. - Compute HMAC-SHA256 over
<t>.<raw body>, as lowercase hex, keyed with the whole secret as text —whsec_included. Use the raw bytes you received — parse the JSON afterwards. - Accept the request if any
v1matches, compared in constant time. - Reject it if
tis more than 5 minutes old. We don’t enforce this, but it stops replays. Each retry is signed again with a newt.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (string or Buffer)
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = header.split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex");
return parts.filter(([k]) => k === "v1").some(([, sig]) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
if t is None or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, expected) for k, v in parts if k == "v1")using System.Security.Cryptography;
using System.Text;
static bool Verify(string rawBody, string header, string secret, int toleranceSec = 300)
{
var parts = header.Split(',').Select(p => p.Split('=', 2)).ToList();
var t = parts.FirstOrDefault(p => p[0] == "t")?[1];
if (t is null || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > toleranceSec)
return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"))).ToLowerInvariant();
return parts.Where(p => p[0] == "v1" && p.Length == 2).Any(p => CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(p[1]), Encoding.UTF8.GetBytes(expected)));
}Retries and redelivery
Reply with any 2xx to accept an event. A timeout (10 seconds), a redirect (we don’t follow them) or any other status counts as a failure, and we try again. Delivery is at least once, so the same event can arrive twice — drop duplicates by id.
| Attempt | Wait before it | Attempt | Wait before it |
|---|---|---|---|
| 1 | Sent right away | 5 | 2 hours |
| 2 | 1 minute | 6 | 12 hours |
| 3 | 5 minutes | 7 | 24 hours |
| 4 | 30 minutes | 8 | 48 hours |
That’s 8 attempts in all: the first and 7 retries over about 3.6 days. After the eighth the delivery gives up (its gave_up_at is set). An endpoint that fails more than 20 attempts in a row is turned off and gets no new events until you turn it back on in Settings › Developers or with PATCH /v1/webhooks/{id} and { "enabled": true }; deliveries already waiting carry on from there.
To send an event again, press Redeliver in Settings › Developers › Deliveries, or call POST /v1/webhooks/{id}/deliveries/{did}/redeliver: it tries once straight away (up to 5 seconds) and tells you what your endpoint answered. GET /v1/webhooks/{id}/deliveries lists the 100 most recent deliveries with their attempts, status and response.
Rotating the secret
Rotate a secret in Settings › Developers or with POST /v1/webhooks/{id}/rotate_secret; the response has the new secret. The old secret keeps working for 24 hours. During that time each request is signed with both, new first, so the header has two v1 values — code that accepts any matching v1 keeps working while you switch over.
SignAnything-Signature: t=1793816561,v1=9b1c7e…new,v1=5d4140…old
Testing
- Send test in Settings › Developers, or
POST /v1/webhooks/{id}/test, sends apingevent to the endpoint and tries it once straight away. To try a real event’s shape, send{ "type": "envelope.completed" }(any event the endpoint receives): you get a realistic sample with sample ids, marked"test": true. - For the real sequence, add an endpoint with a test key, send a test envelope and sign it through a signing URL. You’ll get
envelope.sent,recipient.sent,recipient.viewed,recipient.signedandenvelope.completed, withlivemode: false.
{
"id": "evt_9d3f7b1e5a2c4e8b0d6f2a4c8e1b3d5f",
"object": "event",
"type": "ping",
"created": "2026-11-04T18:22:41.512Z",
"livemode": false,
"api_version": "v1",
"test": true,
"data": { "object": { "message": "Test event from SignAnything." } }
}