Gareth Wilson Gareth Wilson

Guide to Sendoso Webhooks: Features and Best Practices

Published


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: 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

FeatureDetails
AvailabilityEnabled per organisation by Sendoso support
ConfigurationWebhooks portal, linked from the Sendoso integrations page
SubscriptionAny or all of 28 send.* event types per endpoint
Payloadsend_gid and status_changed_at for every event type
Headerssvix-id, svix-timestamp, svix-signature
SchemeBase64 HMAC-SHA256 over {svix-id}.{svix-timestamp}.{raw body}
Secretwhsec_-prefixed, unique per endpoint
Timestamp toleranceSendoso says timestamps more than five minutes from the current time are rejected
Response window15 seconds
RetriesImmediately, then 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h
Auto-disableAfter 5 days of failed attempts
RecoveryResend 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>:

GroupEvents
Shipping and deliverysend.order_received, send.fulfilling, send.fulfillment_issue, send.out_of_stock, send.shipped, send.delivered, send.undeliverable
eGiftssend.sent, send.clicked, send.opened, send.redeemed, send.expired
Lifecyclesend.initiated, send.pending_approval, send.pending_sendoso_approval, send.canceled, send.failed, send.refunded, send.bounced, send.insufficient_funds
Address confirmationsend.confirming_address, send.confirmation_canceled
Amazonsend.amazon_fulfilling, send.amazon_shipped
Recipient email checkssend.blocked, send.email_blacklist, send.invalid_email_format, send.suspicious_email

The documented payload is the same for every event type:

{
  "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.

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:

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 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.

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.

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 sits in front of your receiver, verifies Sendoso's signatures, and holds each update until your service has processed it.

Get started with Hookdeck for free and handle Sendoso webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.