Sharing & feedback
Webhooks
Get signed HTTPS callbacks when drops are viewed, commented on, approved and more.
A webhook is an HTTPS endpoint of yours that Slingdrop calls when something happens to your drops: a file finishes uploading, someone opens a link, leaves a comment or signs off. Each delivery is a JSON POST, signed with a secret only you and Slingdrop know, and retried for about a day if your endpoint doesn't answer.
Create a webhook#
Webhooks are managed with your API key. You can also create them in Settings → Developer. An account can have up to 20.
/webhooksCreate a webhook (the secret is returned once)urlstringrequired- Your endpoint. Must be
https://, at most 500 characters, and on the public internet (localhost and private ranges are rejected). eventsstring[]required- One or more event names from the list below.
organizationIdstring- A team id, to also receive events for drops sent as that team.
curl https://api.slingdrop.my/v1/webhooks \
-H "Authorization: Bearer $SLINGDROP_API_KEY" \
-H "content-type: application/json" \
-d '{"url":"https://example.com/hooks/slingdrop","events":["drop.viewed","comment.created","approval.created"]}'import { Slingdrop } from "@slingdrop/sdk";
const sd = new Slingdrop({ apiKey: process.env.SLINGDROP_API_KEY });
const hook = await sd.webhooks.create("https://example.com/hooks/slingdrop", ["drop.viewed", "comment.created", "approval.created"]);
console.log(hook.secret); // whsec_… store it now: it is never shown againThe response carries the signing secret. Keep it with your other secrets; it starts with whsec_.
{
"id": "…",
"url": "https://example.com/hooks/slingdrop",
"events": ["drop.viewed", "comment.created", "approval.created"],
"secret": "whsec_…"
}List, delete and inspect deliveries#
/webhooksYour webhooks, plus the list of event names/webhooks/{id}Delete a webhook/webhooks/{id}/deliveriesThe last 100 delivery attemptsGET /webhooks returns { data: [{ id, url, events, active, createdAt }], events: [...] }. Each delivery record is { id, event, statusCode, attempt, deliveredAt, createdAt }: statusCode is null when your endpoint couldn't be reached, and deliveredAt is set only for a 2xx answer.
const { data, events } = await sd.webhooks.list();
const attempts = await sd.webhooks.deliveries(data[0].id);
await sd.webhooks.delete(data[0].id);What a delivery looks like#
Every event is sent as the same envelope. data depends on the event.
POST /hooks/slingdrop HTTP/1.1
content-type: application/json
user-agent: Slingdrop-Webhooks/1.0
slingdrop-event: comment.created
slingdrop-signature: t=1767225600,v1=5f2b…c9
{"id":"evt_…","type":"comment.created","createdAt":"2026-01-01T00:00:00.000Z","data":{…}}idstring- Unique per event (
evt_…). A retried delivery keeps the same id, so use it to ignore duplicates. typestring- The event name, also sent in the
Slingdrop-Eventheader. createdAtstring- When the event happened (ISO 8601).
dataobject- The event's payload, below.
Events#
Events fire for drops you own, and for drops sent as a team when the webhook was created with that team's organizationId.
| Event | When | data |
|---|---|---|
drop.created | A drop is created (before its files finish uploading). | { id, url, title, files } |
drop.ready | Every file of a new drop has uploaded and the link is live. | { id, url } |
drop.viewed | Someone other than you opens the drop. Repeat visits within 30 minutes count as one view. | { id, country, device, viewer } |
drop.downloaded | Someone other than you downloads a file. “Download all” sends one event. | { dropId, fileId } |
drop.expired | The drop expired, or a burn-after-reading drop was opened and removed. | { id, reason } |
comment.created | A comment or reply is posted. | { dropId, commentId, author, content, anchor } |
approval.created | Someone approves or requests changes. | { dropId, id, versionId, fileId, decision, signerName, signerEmail, signerVerified, note, createdAt } |
version.created | A new version goes live behind the same link. | { dropId, versionId, number, note } |
request.fulfilled | Someone uploads to one of your file requests. | { requestId, dropId, uploader, itemId } |
Payload details#
drop.viewed:countryis a two-letter code ornull;deviceis"Desktop","Mobile"or"Tablet";vieweris the viewer's email when the drop is private and they're signed in, otherwisenull(viewers of public and passcode drops aren't identified).drop.expired:reasonis"expired"or"burned".comment.created:anchorsays where the comment sits, for example{ "type": "pdf_pin", "page": 3, "x": 0.4, "y": 0.2 },{ "type": "video_timestamp", "t": 42 }or{ "type": "file" }for a comment on the whole file. On Vault dropscontentis ciphertext.approval.created:decisionis"approved"or"changes_requested".fileIdis set when the sign-off was for one file, andsignerVerifiedis true when the signer was signed in with a verified email.drop.readyfires once, for the first version. Later uploads sendversion.created.
Verify the signature#
Each delivery has a Slingdrop-Signature header like t=1767225600,v1=5f2b…. v1 is the hex HMAC-SHA256 of `${t}.${body}` (the timestamp, a dot, then the raw request body) keyed with your webhook secret. Compute it over the raw body, before parsing JSON, compare in constant time, and reject old timestamps so a captured request can't be replayed.
import { verifyWebhook } from "@slingdrop/sdk";
export async function POST(req: Request) {
const body = await req.text(); // the raw body, not parsed JSON
const ok = await verifyWebhook(process.env.SLINGDROP_WEBHOOK_SECRET!, req.headers.get("slingdrop-signature"), body);
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body) as { id: string; type: string; createdAt: string; data: Record<string, unknown> };
if (event.type === "approval.created" && event.data.decision === "approved") {
// ship it
}
return new Response("ok");
}import { createHmac, timingSafeEqual } from "node:crypto";
/** True when `header` (Slingdrop-Signature) matches the raw body and is under 5 minutes old. */
export function verifySlingdrop(secret: string, header: string | null, rawBody: string, toleranceSec = 300) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}import express from "express";
import { verifySlingdrop } from "./verify";
const app = express();
// express.text keeps the body as the exact string that was signed.
app.post("/hooks/slingdrop", express.text({ type: "application/json" }), (req, res) => {
if (!verifySlingdrop(process.env.SLINGDROP_WEBHOOK_SECRET!, req.get("slingdrop-signature") ?? null, req.body)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body);
console.log(event.type, event.data);
res.send("ok");
});verifyWebhook(secret, signature, body, toleranceSec = 300) from @slingdrop/sdk uses Web Crypto, so it runs in Node 20+, Workers, Deno, Bun and edge runtimes.
Retries and timeouts#
Answer with any 2xx status within 10 seconds. Anything else (a non-2xx status, a timeout, a connection error) is retried with growing delays: 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 6 hours, 6 hours and 4 hours, about 24 hours in all. Every attempt shows up under GET /webhooks/{id}/deliveries.
Respond first, work later
Return 200 as soon as the signature checks out and do slow work in the background. Deliveries can arrive more than once and out of order, so key your handling on the event id.