# Guide to ShipHero Webhooks: Features and Best Practices

ShipHero webhooks notify your systems about fulfillment activity: an order is allocated, a shipment updates, inventory changes. If you're building on ShipHero, webhooks are how you react to warehouse and order events without polling the GraphQL API.

This guide covers how ShipHero webhooks work, the webhook types, how to verify the `x-shiphero-hmac-sha256` header, the delivery behavior, and the best practices for production.

## What are ShipHero webhooks?

ShipHero webhooks are HTTP POSTs delivered to a URL you register per webhook type via the GraphQL API. Each is signed with an HMAC-SHA256 of the raw JSON body, base64-encoded, in the `x-shiphero-hmac-sha256` header. The key is the app's `shared_signature_secret`, returned once by the `webhook_create` mutation.

## ShipHero webhook features

| Feature | Details |
| --- | --- |
| Configuration | GraphQL `webhook_create` mutation (`name` = webhook type, `url`, `shop_name`) |
| Signature header | `x-shiphero-hmac-sha256` |
| Signature scheme | base64 HMAC-SHA256 over the raw body, keyed with `shared_signature_secret` |
| Secret | Returned once by `webhook_create` |
| Deduplication | `X-Shiphero-Message-ID` |
| Delivery | ~10s timeout (20s for Generate Label), up to 5 retries per trigger |
| While disabled | Events are discarded (not queued) |
| SDK | None |

## Common webhook types

ShipHero webhook type names are Title Case strings (the `name` you pass to `webhook_create`):

| Type | Fires when |
| --- | --- |
| `Order Allocated` | An order is allocated |
| `Shipment Update` | A shipment updates |
| `Inventory Update` | Inventory changes |
| `Order Canceled` | An order is canceled |
| `Tote Complete` | A tote is completed |
| `PO Update` | A purchase order updates |

Other types include `Return Update`, `Package Added`, and `Generate Label Webhook`. Register one webhook per type; consult ShipHero's webhook reference for the full list.

## Setting up ShipHero webhooks

Register via the GraphQL `webhook_create` mutation, passing the exact webhook type string as `name`, your `url`, and `shop_name`. The mutation returns the `shared_signature_secret` once, capture it. Note that ShipHero does not queue events while a webhook is disabled; they're discarded.

## Securing ShipHero webhooks

Each delivery carries an `x-shiphero-hmac-sha256` header: a base64 HMAC-SHA256 of the raw JSON body, keyed with your `shared_signature_secret`. This is a plain HMAC of the raw body (not the body concatenated with an account id or anything else). Compute over the exact raw bytes and compare in constant time.

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

const SECRET = process.env.SHIPHERO_SIGNATURE_SECRET; // shared_signature_secret

function verify(rawBody, signature) {
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(rawBody)
    .digest("base64");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers["x-shiphero-hmac-sha256"])) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge fast (~10s timeout)
  processQueue.add({
    messageId: req.headers["x-shiphero-message-id"], // dedupe on this
    body: JSON.parse(req.body),
  });
});

```

The same check in Python:

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

SECRET = os.environ["SHIPHERO_SIGNATURE_SECRET"].encode()

def verify(raw_body: bytes, signature: str) -> bool:
    digest = hmac.new(SECRET, raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    return hmac.compare_digest(expected, signature or "")

```

## ShipHero webhook limitations and pain points

### Events are discarded while a webhook is disabled

The Problem: ShipHero doesn't queue events for a disabled webhook, they're dropped. A webhook that's disabled (or an endpoint down long enough to be disabled) loses events permanently.

Why It Happens: ShipHero discards rather than buffering for disabled webhooks.

Workarounds:

* Keep the webhook enabled and the endpoint healthy; reconcile via the GraphQL API for anything you can't afford to miss.

How Hookdeck Can Help: Hookdeck always accepts deliveries and absorbs downstream failures itself, keeping the webhook enabled while it durably queues events, so a downstream outage doesn't drop them.

### The secret is returned once

The Problem: `webhook_create` returns the `shared_signature_secret` only once. Lose it and you can't verify without recreating the webhook.

Why It Happens: ShipHero shows the secret at creation only.

Workarounds:

* Capture and store the secret securely when you create the webhook.

How Hookdeck Can Help: Hookdeck verifies at the edge, so the secret lives in one place.

### Title Case type names and per-type webhooks

The Problem: Webhook types are Title Case strings (`Order Allocated`, not `order_allocated`), and you register one webhook per type, so a typo or wrong casing fails registration or routing.

Why It Happens: ShipHero uses exact Title Case type strings.

Workarounds:

* Register with the exact type string and route on it; keep a list of the types you use.

How Hookdeck Can Help: Hookdeck consolidates multiple ShipHero webhooks into one ingestion point with routing.

### Retries and duplicates

The Problem: Up to 5 retries per trigger (within a ~10s timeout, 20s for Generate Label) means duplicates.

Why It Happens: At-least-once delivery favors eventual delivery.

Workarounds:

* Dedupe on `X-Shiphero-Message-ID` and make side effects idempotent.

How Hookdeck Can Help: Hookdeck deduplicates on the message id at the edge. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify the plain HMAC over the raw body

Compute base64 HMAC-SHA256 over the raw body with `shared_signature_secret` and compare in constant time. It's a plain HMAC, nothing concatenated.

### Keep webhooks enabled and healthy

Since disabled webhooks drop events, keep the endpoint healthy and reconcile via the API for critical data.

### Dedupe on the message id

Use `X-Shiphero-Message-ID` as an idempotency key across the up-to-5 retries.

### Acknowledge fast, process asynchronously

Return 200 within the timeout and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

ShipHero webhooks verify with an `x-shiphero-hmac-sha256` base64 HMAC over the raw body, keyed with the `shared_signature_secret` returned once at creation. Use the exact Title Case type names, keep webhooks enabled (disabled ones drop events), dedupe on `X-Shiphero-Message-ID`, and acknowledge within the timeout.

[Hookdeck](https://hookdeck.com) verifies the signature, keeps webhooks alive by absorbing downstream failures, deduplicates, and durably queues every event at the edge, so your app processes verified, unique fulfillment events without losing any.

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