NetSendo

Webhooks

NetSendo sends an HTTP POST to your URL when subscribers sign up, unsubscribe, bounce or change tags, when the API queues an email or SMS, when an SMS is sent or fails, and when a Stripe purchase completes or is refunded.

Written for NetSendo 2.7.0 and checked against its source code (the webhook model, the dispatcher and every place that sends an event).

Account webhooks and list webhooks

NetSendo has two kinds of webhooks. Most integrations want the first.

Account webhookList webhook
Set upThrough the API (POST /api/v1/webhooks), the n8n NetSendo Trigger node or the MCP serverIn the list settings under Integration → Webhooks, one URL per list
EventsAll 15 events belowSubscriber events of that list: created, subscribed, unsubscribed, bounced, tag_added, tag_removed
SignedYes, X-NetSendo-Signature (HMAC-SHA256)No
Bodyevent, timestamp, dataevent, list_id, list_name, timestamp, data
TestPOST /api/v1/webhooks/{id}/testSend test webhook button in the list settings
After failuresDeactivated after 10 failures in a rowLogged; stays active

Create a webhook

Register a webhook with an API key that has the webhooks:write scope. name, url (up to 2,048 characters) and at least one event are required; is_active defaults to true. See the API reference for keys and scopes.

curl -X POST https://your-domain.com/api/v1/webhooks \
  -H "Authorization: Bearer ns_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "name": "CRM sync",
    "url": "https://example.com/netsendo-webhook",
    "events": ["subscriber.subscribed", "subscriber.unsubscribed"]
  }'

# 201 Created
{
  "data": {
    "id": 7,
    "name": "CRM sync",
    "url": "https://example.com/netsendo-webhook",
    "events": ["subscriber.subscribed", "subscriber.unsubscribed"],
    "is_active": true,
    "last_triggered_at": null,
    "failure_count": 0,
    "created_at": "2026-11-07T10:00:00.000000Z",
    "updated_at": "2026-11-07T10:00:00.000000Z",
    "secret": "64 random characters, shown only in this response"
  },
  "message": "Webhook created successfully. Save the secret - it will not be shown again."
}
Save the secret
The 64-character signing secret appears only in this response. If it is lost, POST /api/v1/webhooks/{id}/regenerate-secret returns a new one; the old secret stops working at once.
MethodPathPurpose
GET/api/v1/webhooks/eventsThe 15 event names
GET/api/v1/webhooksYour webhooks
GET/api/v1/webhooks/{id}One webhook, with last_triggered_at and failure_count
PUT, PATCH/api/v1/webhooks/{id}Change name, url, events or is_active
DELETE/api/v1/webhooks/{id}Delete it
POST/api/v1/webhooks/{id}/testSend a webhook.test event with sample subscriber data
POST/api/v1/webhooks/{id}/regenerate-secretNew signing secret

What NetSendo sends

Every delivery is a POST with a JSON body made of three fields: event (the event name), timestamp (ISO 8601, UTC) and data (the event payload listed in the events table).

HeaderValue
Content-Typeapplication/json
X-NetSendo-EventThe event name, the same as the event field of the body
X-NetSendo-SignatureHMAC-SHA256 of the raw body with the webhook secret, 64 lowercase hex characters (account webhooks only)
User-AgentNetSendo-Webhook/1.0
Example: subscriber.subscribed from a signup form
POST /netsendo-webhook HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: NetSendo-Webhook/1.0
X-NetSendo-Event: subscriber.subscribed
X-NetSendo-Signature: 5d41402abc4b2a76b9719d911017c592...  (64 hex characters)

{
  "event": "subscriber.subscribed",
  "timestamp": "2026-11-07T10:15:30.000000Z",
  "data": {
    "subscriber": {
      "id": 456,
      "email": "anna@example.com",
      "first_name": "Anna",
      "last_name": "Nowak",
      "phone": null,
      "source": "form:newsletter"
    },
    "list_id": 5,
    "list_name": "Newsletter",
    "source": "form:newsletter"
  }
}

Verify the signature

  1. Read the raw request body before parsing it.
  2. Compute HMAC-SHA256 of those bytes with the webhook secret and encode it as lowercase hex.
  3. Compare the result with X-NetSendo-Signature in constant time. Reject the request if they differ.
  4. Answer with any 2xx status quickly, and do slow work after responding.
Hash the raw body
The body is produced by PHP's json_encode, which writes slashes as \/ and non-ASCII characters as \u escapes. Parsing the JSON and encoding it again gives different bytes and a different signature.
PHP
<?php
// The secret returned when the webhook was created
$secret = getenv('NETSENDO_WEBHOOK_SECRET');

// Raw request body, before any JSON decoding
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_NETSENDO_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);

switch ($event['event']) {
    case 'subscriber.subscribed':
        $email = $event['data']['subscriber']['email'];
        $listId = $event['data']['list_id'];
        // ... your code
        break;
    case 'subscriber.unsubscribed':
        // ...
        break;
}

http_response_code(200);
Node.js (Express)
import crypto from "node:crypto"
import express from "express"

const app = express()
const secret = process.env.NETSENDO_WEBHOOK_SECRET

// express.raw keeps the exact bytes NetSendo signed
app.post("/netsendo-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("X-NetSendo-Signature") ?? ""
  const expected = crypto.createHmac("sha256", secret).update(req.body).digest("hex")

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  if (!valid) return res.status(401).send("Invalid signature")

  const { event, timestamp, data } = JSON.parse(req.body.toString("utf8"))

  if (event === "subscriber.subscribed") {
    console.log(data.subscriber.email, "joined list", data.list_id)
  }

  res.sendStatus(200)
})

app.listen(3000)

The signature has no separate timestamp header. The timestamp field is inside the signed body, so you can trust it and reject deliveries older than you accept.

Events

These are the 15 event names a webhook can subscribe to (Webhook::EVENTS in NetSendo). The fields are those of the data object.

EventWhen it firesdata fields
subscriber.createdA contact signs up to a list (form, API, import or panel). Sent together with subscriber.subscribed.subscriber, list_id, list_name, source; form signups also form_id, form_name
subscriber.subscribedA contact is added to a list.subscriber, list_id, list_name, source; form signups also form_id, form_name
subscriber.resubscribedA contact who had unsubscribed or was inactive signs up to the same list again through a form or the API.subscriber, list_id, list_name, previous_status; form signups also form_id, form_name
subscriber.updatedA subscriber is changed through the API, or an already active subscriber submits a form or the API signup again.subscriber; on signups also list_id, list_name (and form_id, form_name)
subscriber.deletedA subscriber is deleted through the API.subscriber_id, email
subscriber.unsubscribedA subscriber leaves a list: unsubscribe link, preference page, panel, API membership change, suppression or list hygiene.subscriber, list_id, list_name, reason
subscriber.bouncedAn email to the subscriber bounced.subscriber, bounce_type, bounce_reason, message_id
subscriber.tag_addedA tag is added to a subscriber.subscriber, tag_id, tag_name
subscriber.tag_removedA tag is removed from a subscriber.subscriber, tag_id, tag_name
email.queuedAn email is queued through POST /api/v1/email/send or /email/batch.message_id, email, subject, mailbox, scheduled_at; batch: message_id, type ("batch"), recipients_count, subject, mailbox, scheduled_at
sms.queuedAn SMS is queued through POST /api/v1/sms/send or /sms/batch.message_id, phone, content, provider, scheduled_at; batch: message_id, type ("batch"), recipients_count, provider
sms.sentThe SMS provider accepted an SMS (campaigns, funnels and API sends).message_id, funnel_step_id, subscriber_id, phone, provider, content, provider_message_id, credits, parts
sms.failedAn SMS will not be sent. Not sent for an attempt that is going to be retried.message_id, funnel_step_id, subscriber_id, phone, provider, error, error_code
stripe.purchase_completedA Stripe checkout for one of your NetSendo products completed (Stripe event checkout.session.completed).transaction_id, product (id, name, stripe_product_id), customer_email, customer_name, amount, formatted_amount, currency, status, subscriber (id, email, first_name, last_name), stripe_session_id, stripe_payment_intent_id, created_at
stripe.payment_refundedSuch a payment was refunded (Stripe event charge.refunded).Same fields as stripe.purchase_completed

One change can produce several events: a signup sends subscriber.created and subscriber.subscribed, and some API calls send an event of their own besides the general one. Make your handler idempotent, for example by keying on the event name, the subscriber ID and the list ID.

The subscriber object

Events raised by signups through forms, the panel, imports, unsubscribe links, bounces and tag changes carry a short subscriber object: id, email, first_name, last_name, phone and source, as in the example above.

Events sent by the subscriber API itself (POST and PUT /api/v1/subscribers, batch import) carry the full API representation of the subscriber. Its custom_fields object holds every placeholder value of the contact: the standard fields (with fname and lname as aliases) followed by your custom fields.

"subscriber": {
  "id": 456,
  "email": "anna@example.com",
  "first_name": "Anna",
  "last_name": "Nowak",
  "phone": null,
  "status": "active",
  "contact_list_id": 5,
  "contact_list_ids": [5, 8],
  "device": null,
  "ip_address": null,
  "source": "api",
  "language": null,
  "opens_count": 3,
  "clicks_count": 1,
  "tags": [
    {
      "id": 1,
      "name": "VIP",
      "color": "#f59e0b",
      "description": null,
      "created_at": "2026-10-01T08:00:00.000000Z",
      "updated_at": "2026-10-01T08:00:00.000000Z"
    }
  ],
  "custom_fields": {
    "email": "anna@example.com",
    "first_name": "Anna",
    "last_name": "Nowak",
    "fname": "Anna",
    "lname": "Nowak",
    "phone": "",
    "device": "",
    "ip_address": "",
    "subscribed_at": "2026-11-07 10:15:30",
    "confirmed_at": "",
    "source": "api",
    "language": "",
    "city": "Berlin"
  },
  "subscribed_at": "2026-11-07T10:15:30.000000Z",
  "confirmed_at": null,
  "last_opened_at": "2026-11-07T12:00:00.000000Z",
  "last_clicked_at": null,
  "created_at": "2026-11-07T10:15:30.000000Z",
  "updated_at": "2026-11-07T12:00:00.000000Z"
}

Delivery, failures and logs

  • Deliveries are queued and sent by the queue worker, so they pause while the queue container is down and go out when it runs again. Each delivery is a separate job: do not rely on the order in which events arrive.
  • NetSendo waits up to 10 seconds for an answer. Any 2xx status counts as delivered.
  • A failed delivery (another status, a timeout or a connection error) is logged and not sent again. A successful one resets the failure counter.
  • After 10 failures in a row the webhook is deactivated (is_active: false) and receives nothing until you turn it back on:
curl -X PUT https://your-domain.com/api/v1/webhooks/7 \
  -H "Authorization: Bearer ns_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{ "is_active": true }'

Every attempt, with its status code, the first 1,000 characters of your response and the duration, is recorded in the webhook log, which you can browse in the panel under Settings → Logs. GET /api/v1/webhooks/{id} shows last_triggered_at and the current failure_count.

n8n and MCP

The NetSendo Trigger node of the n8n integration registers an account webhook through the API when a workflow is activated and deletes it when the workflow is deactivated, so you do not create it by hand. Its API key needs webhooks:read and webhooks:write. AI assistants connected through the MCP server can list, create, test and delete webhooks with the same endpoints.

Frequently asked questions

How do I verify a NetSendo webhook signature?

Compute HMAC-SHA256 of the raw request body with the webhook's secret, as a lowercase hex string, and compare it with the X-NetSendo-Signature header using a constant-time comparison such as hash_equals in PHP or crypto.timingSafeEqual in Node.js. Hash the raw body, not re-encoded JSON.

Does NetSendo retry failed webhook deliveries?

No. Each event is sent once with a 10-second timeout. A response outside 2xx, a timeout or a connection error is logged as a failed delivery and not sent again. After 10 failures in a row NetSendo deactivates the webhook.

Where do I find the webhook secret?

It is returned once, in the response that creates the webhook (POST /api/v1/webhooks). If you lose it, call POST /api/v1/webhooks/{id}/regenerate-secret, which returns a new one and makes the old one invalid.

How do I turn a deactivated webhook back on?

Fix the endpoint, then send PUT /api/v1/webhooks/{id} with {"is_active": true}. The failure counter goes back to zero with the next successful delivery.

Which events can a NetSendo webhook receive?

Fifteen: subscriber.created, subscriber.updated, subscriber.deleted, subscriber.subscribed, subscriber.resubscribed, subscriber.unsubscribed, subscriber.bounced, subscriber.tag_added, subscriber.tag_removed, email.queued, sms.queued, sms.sent, sms.failed, stripe.purchase_completed and stripe.payment_refunded. GET /api/v1/webhooks/events returns the same list.

Are list webhooks signed?

No. The webhook URL set in a list's settings has no secret and is delivered without X-NetSendo-Signature. Use an account webhook created through the API when you need to verify the sender.