Webhooks
Polling for replies works and wastes both our time. Register a URL instead and Selda POSTs you a signed payload whenever something happens.
Register one
In the app: Settings → Apps → Outbound webhooks → Add. Or:
curl -X POST https://api.selda.ai/mcp/mutate \
-H "Authorization: Bearer $SELDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fn": "webhooks.create",
"args": {
"url": "https://example.com/selda/events",
"events": ["reply.received", "lead.status_changed"],
"description": "My app"
}
}'{ "value": { "webhookId": "...", "secret": "whsec_..." } }The secret is shown once. It is what proves a delivery came from Selda; store it where your receiver can read it.
Subscribe with "events": ["*"] to receive everything.
Events
| Event | When |
|---|---|
lead.added | A lead is created via the API (leads.add / leads.addBatch) |
reply.received | An inbound reply arrives in the Sales Inbox |
message.sent | A message is sent to a lead (campaign sends and API sends) |
lead.status_changed | Status changes to responded / qualified, or the CRM stage moves (contacted → replied → meeting → customer / lost) |
campaign.completed | A campaign run finishes sending |
credits.low | The balance drops below what one run costs. Fires once per crossing, before a campaign runs out mid-send. Read the exact number from credits.info, it is derived from measured cost and is not a fixed 20 |
meeting.booked | A prospect books a meeting through Selda |
draft.ready | Selda finished writing a draft, for example after events.ingest with autoAdvance |
test.ping | Sent by the “Send test” button, verify your endpoint with it |
What arrives
Every delivery is POST { event, timestamp, data } with two headers: X-Selda-Event and
X-Selda-Signature.
{
"event": "reply.received",
"timestamp": 1720000000000,
"data": {
"inboundMessageId": "...",
"leadId": "...",
"email": "owner@example.fi",
"company": "Example Oy",
"campaignId": "...",
"from": "owner@example.fi",
"subject": "Re: Quick question",
"classification": "positive"
}
}Verify the signature
Do this before trusting anything in the body. The signature is an HMAC-SHA256 of the raw bytes, so verify before parsing, not after.
const crypto = require("crypto");
function verifySeldaWebhook(secret, rawBody, signatureHeader) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}
// Express. Note express.raw, not express.json: re-serialising the body changes the bytes.
app.post("/selda/events", express.raw({ type: "application/json" }), (req, res) => {
if (
!verifySeldaWebhook(
process.env.SELDA_WEBHOOK_SECRET,
req.body,
req.headers["x-selda-signature"],
)
) {
return res.status(401).send("Bad signature");
}
const { event, data } = JSON.parse(req.body);
// handle...
res.sendStatus(200);
});Delivery, retries and being switched off
- 10 second timeout per request.
- On a non-2xx response or a network error, Selda retries up to 2 more times: 30 seconds, then 5 minutes after the first attempt. Retries carry a byte-identical body and signature, so your verification and your idempotency both still work.
- After 10 consecutive deliveries where every attempt failed, the endpoint is auto-disabled
(
active: false,disabledAtset). You can see that inwebhooks.listand in Settings → Apps → Outbound webhooks, and re-enable it once your receiver is fixed.
Nothing is silently dropped: a disabled endpoint is a state you can read, not an absence you have to notice.
No webhook receiver? Use Zapier, Make or n8n
Point the webhook at a “Catch Hook” trigger and do the rest there. The same works in reverse: those
tools can POST to https://api.selda.ai/mcp/mutate with an Authorization: Bearer sk_… header,
so a Selda integration is reachable without writing a backend at all.
Next
- Examples
- Function reference
- Security and privacy, how keys and webhook secrets are stored