Skip to content

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.

FieldTypeDescription
idstringEvent id, like evt_5b1e0c9a…. The same id on every retry — use it to drop duplicates.
objectstringAlways "event".
typestringOne of the events below, like envelope.completed.
createdstringWhen it happened: an ISO 8601 date-time in UTC.
livemodebooleantrue for live events, false for test.
api_versionstring"v1".
testbooleanOnly on events sent with Send test: true, with sample data and sample ids.
data.objectobjectEnvelope and recipient events: the envelope, with its recipients and documents. KeepAnything events: the file, folder, share, link or file request.
data.recipient_idstringRecipient events only: the recipient it’s about.

Event catalogue

33 events. Open a row to see an example payload.

EventSent whenExample
Signing 11 events
envelope.sentAn envelope goes out
envelope.completedEveryone has signed
envelope.declinedA recipient declines, so the envelope is declined
envelope.voidedThe sender voids it
envelope.expiredIt expires unsigned
recipient.sentA recipient gets their turn (or a signing URL is made for them)
recipient.viewedA recipient opens it
recipient.signedA recipient signs
recipient.declinedA recipient declines
recipient.auth_failedA recipient fails the identity check
recipient.reassignedA signer passes their turn to someone else
Test 1 event
pingYou press Send test, or call POST /v1/webhooks/{id}/test
Files KeepAnything · live only 8 events
file.createdA file is added
file.version_addedA new version is ready (also when a new file has passed the virus check)
file.renamedA file is renamed
file.movedA file is moved
file.trashedMoved to Trash
file.restoredRestored from Trash
file.deletedDeleted for good
file.infectedThe virus check finds a threat
Folders KeepAnything · live only 6 events
folder.createdA folder is added
folder.renamedA folder is renamed
folder.movedA folder is moved
folder.trashedMoved to Trash
folder.restoredRestored from Trash
folder.deletedDeleted for good
Sharing KeepAnything · live only 7 events
share.addedSomeone is given access
share.changedSomeone’s access changes
share.removedSomeone’s access is removed
link.createdA share link is created
link.changedA link’s settings change
link.disabledA link is turned off
file_request.uploadedSomeone 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
  1. Split the header on commas. t is the Unix time we signed it; each v1 is a signature.
  2. 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.
  3. Accept the request if any v1 matches, compared in constant time.
  4. Reject it if t is more than 5 minutes old. We don’t enforce this, but it stops replays. Each retry is signed again with a new t.
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.

AttemptWait before itAttemptWait before it
1Sent right away52 hours
21 minute612 hours
35 minutes724 hours
430 minutes848 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 a ping event 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.signed and envelope.completed, with livemode: 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." } }
}