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

EventWhen
lead.addedA lead is created via the API (leads.add / leads.addBatch)
reply.receivedAn inbound reply arrives in the Sales Inbox
message.sentA message is sent to a lead (campaign sends and API sends)
lead.status_changedStatus changes to responded / qualified, or the CRM stage moves (contactedrepliedmeetingcustomer / lost)
campaign.completedA campaign run finishes sending
credits.lowThe 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.bookedA prospect books a meeting through Selda
draft.readySelda finished writing a draft, for example after events.ingest with autoAdvance
test.pingSent 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, disabledAt set). You can see that in webhooks.list and 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