# Guide to Sendoso Webhooks: Features and Best Practices

Sendoso webhooks report what happens to your sends after you create them: a gift ships, gets delivered, an eGift is opened or redeemed, a send needs approval, or something goes wrong with stock, funds or the recipient's address. If you use Sendoso for corporate gifting and direct mail, webhooks are how those status changes reach your CRM or internal tools without polling.

This guide covers how to turn on Sendoso webhooks, how they are signed, the send events and their payload, the retry and recovery behaviour, and the best practices for running a Sendoso receiver in production.

## What are Sendoso webhooks?

Sendoso webhooks are send status notifications. The events mirror the states you see in Sendoso's send tracker, and each one fires when a send reaches that state.

Webhooks are not on by default. You ask Sendoso support to enable them for your organisation, and the integrations page then gives you the URL of a webhooks portal, where you create endpoints and choose their events. Deliveries run on [Svix](/webhooks/platforms/guide-to-svix-webhooks-features-and-best-practices): each request carries `svix-id`, `svix-timestamp` and `svix-signature` headers, signed with a `whsec_` secret that is unique to the endpoint.

## Sendoso webhook features

| Feature | Details |
| --- | --- |
| Availability | Enabled per organisation by Sendoso support |
| Configuration | Webhooks portal, linked from the Sendoso integrations page |
| Subscription | Any or all of 28 `send.*` event types per endpoint |
| Payload | `send_gid` and `status_changed_at` for every event type |
| Headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | Base64 HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | `whsec_`-prefixed, unique per endpoint |
| Timestamp tolerance | Sendoso says timestamps more than five minutes from the current time are rejected |
| Response window | 15 seconds |
| Retries | Immediately, then 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h |
| Auto-disable | After 5 days of failed attempts |
| Recovery | Resend one message, or recover and replay failed messages from a point in time |
| Source IPs (US) | `44.228.126.217`, `50.112.21.217`, `52.24.126.164`, `54.148.139.208`, `2600:1f24:64:8000::/52` |

## Common events

Sendoso documents 28 event types, all named `send.<status>`:

| Group | Events |
| --- | --- |
| Shipping and delivery | `send.order_received`, `send.fulfilling`, `send.fulfillment_issue`, `send.out_of_stock`, `send.shipped`, `send.delivered`, `send.undeliverable` |
| eGifts | `send.sent`, `send.clicked`, `send.opened`, `send.redeemed`, `send.expired` |
| Lifecycle | `send.initiated`, `send.pending_approval`, `send.pending_sendoso_approval`, `send.canceled`, `send.failed`, `send.refunded`, `send.bounced`, `send.insufficient_funds` |
| Address confirmation | `send.confirming_address`, `send.confirmation_canceled` |
| Amazon | `send.amazon_fulfilling`, `send.amazon_shipped` |
| Recipient email checks | `send.blocked`, `send.email_blacklist`, `send.invalid_email_format`, `send.suspicious_email` |

The documented payload is the same for every event type:

```json
{
  "send_gid": "<string>",
  "status_changed_at": "<ISO 8601 string>"
}

```

`send_gid` identifies the send and `status_changed_at` is when its status changed. Neither field names the event, and Sendoso documents no header that does. Sendoso's schema uses placeholder values, so look at a real message in the portal before you build on the exact shape.

## Setting up Sendoso webhooks

1. Contact Sendoso support (developers@sendoso.com) and ask them to enable webhooks for your organisation.
2. Open the integrations page in Sendoso and follow the link to the webhooks portal.
3. Create an endpoint with your HTTPS URL and subscribe it to the events you need.
4. Copy the endpoint's signing secret from the portal. It starts with `whsec_`. Store it server-side, for example as `SENDOSO_WEBHOOK_SECRET`.
5. If your endpoint sits behind a firewall, allow the US source IPs listed above.

## Securing Sendoso webhooks

Sendoso's security page shows the Svix scheme in Ruby. Build the signed content `{svix-id}.{svix-timestamp}.{body}` from the raw body, base64-decode the part of the secret after `whsec_`, compute an HMAC-SHA256, base64-encode it, and compare it with each entry in the space-separated `svix-signature` header in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifySvixSignature(rawBody, headers, secret) {
  const msg_id = headers['svix-id'];
  const msg_timestamp = headers['svix-timestamp'];
  const signature_header = headers['svix-signature'];
  if (!msg_id || !msg_timestamp || !signature_header) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The HMAC key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${msg_timestamp}.`, 'utf8'), rawBody]);
  const expected = Buffer.from(
    crypto.createHmac('sha256', key).update(signed_content).digest('base64'),
    'utf8'
  );

  // The header can hold several space-separated "v1,<signature>" entries.
  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/sendoso', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.SENDOSO_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifySvixSignature(req.body, req.headers, secret)) {
    return res.sendStatus(403);
  }

  // Optional: reject timestamps more than five minutes from your clock.
  // const age = Math.abs(Date.now() / 1000 - Number(req.get('svix-timestamp')));
  // if (age > 300) return res.sendStatus(403);

  const { send_gid, status_changed_at } = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.get('svix-id'), queue the update, then respond.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_svix_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    msg_timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and msg_timestamp and signature_header):
        return False
    if not secret or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{msg_timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_svix_signature(raw_body, request.headers, os.environ["SENDOSO_WEBHOOK_SECRET"])

```

You can also use the official Svix libraries, which apply the five-minute timestamp check for you. If you write the check yourself, it needs an accurate clock, and Sendoso recommends NTP.

> **Make Sendoso webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Sendoso's Svix signatures, answers within the 15-second window, and queues every send update until your service has processed it.

Hookdeck's Sendoso source type verifies the `svix-*` headers with the `whsec_` secret you enter, keeping the prefix. Hookdeck does not apply a timestamp window, so a delayed retry still verifies. If you want Sendoso's five-minute check, apply it in your own receiver.

## Sendoso webhook limitations and pain points

### The payload doesn't name the status

**The Problem:** Your handler gets a `send_gid` and a time, but not the status the send moved to.

**Why It Happens:** Sendoso documents one two-field schema for all 28 event types, and no header carries the event type.

**Workarounds:**

* Subscribe each endpoint only to the events it should handle, so the endpoint implies the status.
* Look up the send's current status through Sendoso's API using `send_gid`.

**How Hookdeck Can Help:** You can create one Hookdeck source per Sendoso endpoint and route each to the right handler, so the status is known from where the event came in.

### Updates can arrive out of order

**The Problem:** A `send.shipped` update is processed after `send.delivered`, and your record goes backwards.

**Why It Happens:** Retries and parallel deliveries mean a later status can arrive before an earlier one, and Sendoso does not promise ordering.

**Workarounds:**

* Store `status_changed_at` per send and ignore any update older than the one you already have.

**How Hookdeck Can Help:** Hookdeck keeps every update it receives, with its headers and body, so you can inspect the sequence for a send and replay updates if you need to rebuild its state.

### Raw body handling breaks verification

**The Problem:** Every signature check fails, even with the right secret.

**Why It Happens:** Sendoso's troubleshooting page calls this the most common issue: the signature covers the raw string body, and re-stringified JSON is rarely identical. Using another endpoint's secret fails the same way, since secrets are unique per endpoint.

**Workarounds:**

* Verify the raw bytes before parsing, and keep one secret per endpoint in your configuration.

**How Hookdeck Can Help:** Hookdeck verifies the bytes Sendoso sent, so your own services receive events that have already been checked.

### Long outages disable the endpoint

**The Problem:** After a sustained failure, Sendoso stops sending to your endpoint.

**Why It Happens:** Sendoso retries over roughly a day, and if all attempts to an endpoint fail for 5 days, the endpoint is disabled.

**Workarounds:**

* Re-enable the endpoint in the webhook dashboard with **Enable Endpoint**.
* Use **Options > Recover Failed Messages** on the endpoint to resend everything that failed during the outage.

**How Hookdeck Can Help:** Hookdeck accepts Sendoso's deliveries even while your service is down, so the endpoint never accumulates failures, and you replay from Hookdeck once you have recovered.

## Best practices

### Verify against the raw body

Use `express.raw()` in Express, `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or `await request.body()` in FastAPI, and parse only after verification.

### Return the right status codes

Sendoso treats any `2xx` as success even if your response body reports an error. Return a non-`2xx` code when you want a retry.

### Deduplicate on `svix-id`

Deliveries are at least once, and Svix keeps the same `svix-id` when it resends a message. Store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Respond within 15 seconds. Sendoso suggests receiving the message, adding it to a queue and returning straight away. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Keep the source IPs as a second layer

Allowing only Sendoso's IPs is useful behind a firewall, but keep verifying signatures. Sendoso prints only the US list.

## Conclusion

Sendoso delivers send status changes through Svix, signed with base64 HMAC-SHA256 over `svix-id`, `svix-timestamp` and the raw body. The payload carries only `send_gid` and `status_changed_at`, so subscribe endpoints narrowly, use the timestamp to discard stale updates, and look up the send when you need its full state.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies Sendoso's signatures, and holds each update until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Sendoso webhooks reliably in minutes.