> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.trysend.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.trysend.com/_mcp/server.

SEND can POST webhook notifications to your URL for package and tracking events. Configure the URL and rotate secrets from **API Settings** in the dashboard.

## Security headers

Every delivery includes:

* **`X-Webhook-Signature`** — HMAC-SHA256 of the payload body using your webhook secret
* **`X-Timestamp`** — Unix time (seconds) when the webhook was sent

Verify by recomputing the HMAC over the **raw JSON body** (same canonicalization your stack uses when comparing signatures).

### JavaScript

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

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}
```

### Ruby

```ruby
require 'openssl'

def verify_webhook_signature(payload, signature, secret)
  expected_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, payload.to_json)
  ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
end
```

### Python

```python
import hmac
import hashlib
import json

def verify_webhook_signature(payload, signature, secret):
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        json.dumps(payload).encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected_signature)
```

## Request shape

**Headers**

```http
Content-Type: application/json
X-Webhook-Signature: <hex_hmac>
X-Timestamp: <unix_seconds>
```

**Body**

```json
{
  "event": "<event_name>",
  "data": {},
  "timestamp": 1705312200
}
```

## Events

### `parcel.created`

Sent when a new package is created for your business.

```json
{
  "event": "parcel.created",
  "data": {
    "parcel_id": "ABC123XYZ",
    "parcel_number": "PCL-12345678",
    "customer_identifier": "YSDX-BONI-234",
    "status": "booked",
    "trade_direction": "import",
    "quantity": 1,
    "declared_weight": 5.2,
    "actual_weight": 5.2,
    "length": 40.0,
    "width": 30.0,
    "height": 20.0,
    "volume": 24000.0,
    "mode": "live",
    "origin_country": "NG",
    "destination_country": "NG",
    "created_at": "2025-01-15T10:30:00Z"
  },
  "timestamp": 1705312200
}
```

### `tracking.{step_name}`

Sent when a tracking step changes. Step names are lowercased with underscores, for example:

* `tracking.booking_initiated`
* `tracking.arrived_warehouse`
* `tracking.departed_warehouse`
* `tracking.arrived_origin_port`
* `tracking.export_clearance`
* `tracking.departed_origin_port`
* `tracking.arrived_destination_port`
* `tracking.customs_clearance`
* `tracking.delivery_in_progress`
* `tracking.delivered`

Example payload:

```json
{
  "event": "tracking.booking_initiated",
  "data": {
    "tracking_step": {
      "step": "Booking Initiated",
      "status": "complete",
      "completed_on": "15 Jan, 2025"
    },
    "parcels": [
      {
        "parcel_id": "ABC123XYZ",
        "parcel_number": "PCL-12345678",
        "customer_identifier": "YSDX-BONI-234",
        "status": "warehouse",
        "trade_direction": "import"
      }
    ]
  },
  "timestamp": 1705312200
}
```

**Step status values:** `complete`, `processing`, `pending`

## Endpoint requirements

1. Respond with **`200 OK` within \~10 seconds**
2. **Verify** the signature before trusting the body
3. Treat deliveries as **at-least-once** — use idempotency (event + timestamp / ids)
4. Non-2xx responses may be retried per SEND policy

## Local testing

Tools such as [ngrok](https://ngrok.com/), [localtunnel](https://localtunnel.github.io/www/), or [webhook.site](https://webhook.site/) help receive webhooks during development.

## Example handler (Node)

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

const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

app.post('/webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  switch (req.body.event) {
    case 'parcel.created':
      break;
    default:
      if (String(req.body.event).startsWith('tracking.')) {
        // handle tracking
      }
  }
  res.status(200).json({ received: true });
});
```

## Example handler (Rails)

```ruby
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_webhook_signature

  def receive
    case params[:event]
    when 'parcel.created'
      handle_parcel_created(params[:data])
    when /^tracking\./
      handle_tracking_update(params[:event], params[:data])
    end
    head :ok
  end

  private

  def verify_webhook_signature
    signature = request.headers['X-Webhook-Signature']
    secret = ENV['WEBHOOK_SECRET']
    expected = OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)
    unless ActiveSupport::SecurityUtils.secure_compare(signature.to_s, expected)
      render json: { error: 'Invalid signature' }, status: :unauthorized
    end
  end
end
```