# AfterShip Webhooks

AfterShip (aftership.com) is the post-purchase platform. Four of its products send
webhooks — Tracking (the flagship shipment-tracking product), Shipping
(formerly Postmen), Returns, and Warranty. They all use the same HMAC
algorithm but send the signature under three different header names.

## When to Use This Skill

* How do I receive AfterShip webhooks?
* How do I verify AfterShip webhook signatures?
* What is the `aftership-hmac-sha256` header and how do I validate it?
* Why is my AfterShip webhook signature verification failing?
* How do I handle `tracking_update`, `edd_revise`, or `tracking_pending_time` events?
* How do I route on `msg.tag` (`InTransit`, `Delivered`, `Exception`, …)?
* How do I verify AfterShip Returns (`as-signature-hmac-sha256`) or Shipping
  (`am-webhook-signature`) webhooks?

## Verification (core)

One algorithm for every product: HMAC-SHA256 over the raw request body, keyed
with the webhook secret as a UTF-8 string (do not base64-decode it), digest
encoded as standard base64. Nothing else is signed — there is no timestamp
header and no replay window, so do not add a tolerance check.

Only the header name differs:

| Product | Header | Value |
| --- | --- | --- |
| Tracking | `aftership-hmac-sha256` | bare base64 digest |
| Returns / Warranty | `as-signature-hmac-sha256` | bare base64 digest |
| Shipping (Postmen), legacy Returns | `am-webhook-signature` | `hmac-sha256=<base64 digest>` |

Node:

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

// Checked in order; the prefix strip is harmless on the two bare headers.
const SIGNATURE_HEADERS = ['aftership-hmac-sha256', 'as-signature-hmac-sha256', 'am-webhook-signature'];

function verifyAfterShipSignature(rawBody, headers, secret) {
  if (!secret) return false; // fail closed — never skip verification
  const name = SIGNATURE_HEADERS.find((h) => headers[h]);
  if (!name) return false;
  const received = String(headers[name]).replace(/^hmac-sha256=/, '');
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  // Guard the length: timingSafeEqual throws when the buffers differ in size.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

Python:

```python
import base64, hashlib, hmac

SIGNATURE_HEADERS = ("aftership-hmac-sha256", "as-signature-hmac-sha256", "am-webhook-signature")

def verify_aftership_signature(raw_body: bytes, headers, secret: str) -> bool:
    if not secret:
        return False  # fail closed
    received = next((headers[h] for h in SIGNATURE_HEADERS if headers.get(h)), None)
    if not received:
        return False
    received = received.removeprefix("hmac-sha256=")
    expected = base64.b64encode(
        hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(received.encode(), expected.encode())

```

> Use the raw body. `express.raw({ type: 'application/json' })`, `await req.text()`
> in a Next.js App Router route, `await request.body()` in FastAPI. Re-serializing
> parsed JSON changes the bytes and breaks the signature. AfterShip's own Shipping
> sample signs `JSON.stringify(webhookPayload)`; sign the raw body instead — it is the
> safe superset.

> Each product has its own secret. Tracking's lives in the Tracking admin, Shipping's
> in the Postmen admin, Returns' and Warranty's in their own settings pages. One secret
> does not span products — use one endpoint (or one Hookdeck source) per product.

> For complete handlers with route wiring, event dispatch, and tests, see:
> 
> * [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/aftership-webhooks/examples/express/)
> * [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/aftership-webhooks/examples/nextjs/)
> * [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/aftership-webhooks/examples/fastapi/)

AfterShip's docs give only these raw HMAC snippets, and the official Node SDK
(`@aftership/tracking-sdk` 17.0.0) has no webhook-verification helper — so implement it
manually as above (mirrored in the examples).

## Common Event Types

Tracking — exactly three event codes. The shipment status is in `msg.tag` /
`msg.subtag`, not in `event`, so route on the tag:

| `event` | Triggered when |
| --- | --- |
| `tracking_update` | A shipment status changes (Info received, In transit, Out for delivery, Available for pickup, Delivered, Failed attempt, Expired, Exception) |
| `edd_revise` | The estimated delivery date changes |
| `tracking_pending_time` | A shipment stays pending past a user-defined threshold |

`msg.tag` values: `Pending`, `InfoReceived`, `InTransit`, `OutForDelivery`,
`AttemptFail`, `Delivered`, `AvailableForPickup`, `Exception`, `Expired`.

Shipping (Postmen) — the event name is in `event_type`: `calculate_rates`,
`create_a_label`, `cancel_a_label`, `manifest_a_label`.

Returns — `return.submitted`, `return.approved`, `return.rejected`,
`return.resolved`, `return.expired`, `return.dropoff.created`, `return.dropoff.updated`,
`return.dropoff.shipment.updated`, `return.restock.created`, `return.shipment.provided`,
`return.shipments.provided`, `return.shipment.recorded`, `return.shipment.updated`,
`return.exchange.order.created`, `return.receiving.created`.

Warranty — `warranty.created`, `warranty.approved`, `warranty.processing`,
`warranty.completed`, `warranty.canceled`, `warranty.rejected`,
`warranty.inbound_shipment.provided`, `warranty.inbound_shipment.updated`,
`warranty.outbound_shipment.provided`, `warranty.outbound_shipment.updated`,
`warranty.item_received`.

See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/aftership-webhooks/references/overview.md) for payload shapes and the full
event tables.

## Environment Variables

```bash
AFTERSHIP_WEBHOOK_SECRET=your_webhook_secret   # Per product — see references/setup.md

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 aftership --path /webhooks/aftership

```

Set the printed HTTPS URL as the webhook URL in the relevant AfterShip admin. Tracking
webhook URLs must use port 80, 443 or 8080, and each organization can register up to
10 Tracking webhook URLs.

## Delivery and Retries

Respond `2xx`. Otherwise AfterShip retries up to 14 times with exponential backoff —
`2^retry × 30s` (30s, 60s, 120s … 122,880s), roughly 68 hours in total. There is no
handshake or challenge: the admin's "Send test webhook" button sends an ordinary delivery
and just expects a 2xx.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/aftership-webhooks/references/overview.md) - AfterShip webhook concepts, all event types, payload shapes
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/aftership-webhooks/references/setup.md) - Configure webhooks and find each product's secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/aftership-webhooks/references/verification.md) - Signature verification details and gotchas