---
title: "Webhooks"
description: "Consuming outgoing webhooks — payload shape, verification, and retry behaviour"
---

> API Reference Index
> Fetch the complete API index at: https://docs.firearmcart.com/api/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

FirearmCart pushes outgoing webhooks to a URL you control when something happens in a store. This page is the **receiving** side: what lands on your endpoint, how to verify it, and how delivery and retries behave.

> **Where the event list lives:** the catalogue of events, which plan each requires, and the dashboard walkthrough for creating a webhook all live on [Webhooks](/integrations/webhooks) in the store setup docs. That page is the source of truth for *which* events exist. This page does not repeat it.

---

## There is no webhook management API

Webhooks are created, edited, enabled, disabled and deleted **in the dashboard only**, under **Settings → Webhooks**. There is no `/v1/webhooks` endpoint — you cannot list, create or subscribe to a webhook programmatically, and there is no API to read delivery logs.

If your integration needs a webhook, it has to be configured once by a human in the store's dashboard. Plan your onboarding around that.

---

## The payload is not a fixed schema

This is the single most important thing to understand before you write a receiver.

FirearmCart does **not** send a standard event object. The body is assembled from the parameter map configured on that specific webhook: each row pairs a key name you choose with either a FirearmCart data field or a literal static value. Two stores subscribed to the same event can send completely different bodies, with different key names, to different endpoints.

Order payloads can include `surcharge` (the credit-card surcharge in dollars) and `shipping_insurance`. Both are `0` when they do not apply. `total` already includes them.

Only two keys are guaranteed:

| Key | Value |
|-----|-------|
| `_event` | The event name that fired — `order.completed`, `tracking.assigned`, and so on |
| `_timestamp` | ISO 8601 timestamp of when the delivery was assembled |

A typical body, for a webhook configured with four parameters:

```json
{
  "order_id": 1042,
  "customer_email": "customer@example.com",
  "total": "329.99",
  "tracking_number": "1Z999AA10123456784",
  "_event": "tracking.assigned",
  "_timestamp": "2026-08-11T15:30:00+00:00"
}
```

Consequences for your receiver:

- **Branch on `_event`, never on which keys are present.** Key names are chosen by whoever configured the webhook.
- **Treat every configured field as optional.** A field that has no value for this event is sent as `null`, and a parameter row with an empty name or empty value is skipped entirely.
- **`_event` and `_timestamp` are reserved.** They are written after your parameters, so a parameter named `_event` is silently overwritten.
- **Unmapped values are sent verbatim.** If the configured value is not a recognised FirearmCart field key, it is treated as a static string and passed through as typed.

### `GET` webhooks send the payload as a query string

A webhook can be configured as `POST` (default) or `GET`. On `GET`, the same assembled payload is serialised into the query string instead of a JSON body — `?order_id=1042&_event=order.completed&…`. The `Content-Type: application/json` header is still sent. Prefer `POST` unless you are integrating with something that can only accept a `GET`.

---

## Headers

| Header | Always present | Value |
|--------|----------------|-------|
| `Content-Type` | Yes | `application/json` |
| `X-Webhook-Secret` | Yes | The secret configured on this webhook, in plaintext |
| `X-Webhook-Event` | Yes | The event name — same value as the body's `_event` |

Any custom headers configured on the webhook are added on top, and **custom headers can overwrite the three above** if you give them the same name. Do not configure a custom header named `X-Webhook-Secret`.

---

## Verification

> **Important:** There is no HMAC signature. `X-Webhook-Secret` carries the shared secret itself, in the clear, on every request. There is no signature over the request body, and no timestamp-based replay protection.

Verify by comparing the header against your copy of the secret, in constant time:

```php
<?php
// PHP receiver
$provided = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';
$expected = getenv('FIREARMCART_WEBHOOK_SECRET');

if (! hash_equals($expected, $provided)) {
http_response_code(401);
exit;
}

$payload = json_decode(file_get_contents('php://input'), true);
```

```js
// Node / Express receiver
import { timingSafeEqual } from "node:crypto";

function verify(req, res, next) {
  const provided = Buffer.from(req.get("x-webhook-secret") ?? "");
  const expected = Buffer.from(process.env.FIREARMCART_WEBHOOK_SECRET);

  if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
return res.sendStatus(401);
  }
  next();
}
```

What this does and does not give you:

- **Proves** the sender knows the secret. Good enough to reject internet noise.
- **Does not prove** the body is unmodified in transit — nothing signs it. Your only integrity guarantee is TLS, so your endpoint **must** be HTTPS.
- **Does not prevent replay.** Anyone who captures one request can resend it forever. Deduplicate on your side.

Because the secret travels on every request, treat it like a password: rotate it in the dashboard if a request log is ever exposed, and never log the full header value.

### Do not trust the payload as fact

Webhook fields are values, not proof. For anything that moves money or inventory, treat the webhook as a *hint* and re-read the authoritative record:

```bash
curl "https://api.firearmcart.com/v1/orders/1042" \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json"
```

---

## Payload quirks

### Amounts are strings

Order amounts in a webhook body are **strings of dollars** — `"329.99"`, not `329.99` and not `32999`. This is a third money format, distinct from both formats the REST API uses. Parse them as decimals, never as integers. See [Conventions](/api/conventions#money-units).

### Order status is the stored status, not the derived one

A webhook's order status field reports the order's **stored** status column. `GET /v1/orders/{order_id}` reports a status **derived from the transaction ledger** at read time. The two can disagree — most visibly around partial reversals, where the API reports `partially_refunded` and the webhook reports whatever was last written. When they differ, the API is authoritative.

### Most of the address fields currently deliver `null`

> **Warning:** Twelve of the sixteen shipping-address and billing-address parameters — first name, last name, address 1, address 2, ZIP and country, for both roles — resolve to `null` in every delivered payload. The webhook builder reads column names that do not exist on the address record. Only **city** and **state** carry real values, for both roles.

Do not map the twelve broken parameters; they will not carry data. If you need the full shipping or billing address for an order, read it from `GET /v1/orders/{order_id}`, which returns `shipping_address` and `billing_address` correctly — note that even there an address is street, city, state and ZIP only, with no recipient name or country.

The customer, order and fulfillment parameter groups are unaffected and deliver real values.

### Fulfillment ID matches the API

The fulfillment identifier in a webhook is the same UUID that `GET /v1/fulfillments/{fulfillment_id}` accepts. You can use it directly as a path parameter. See [Conventions](/api/conventions#identifiers).

---

## Delivery

| Property | Value |
|----------|-------|
| Timing | Queued the moment the event fires; usually delivered within seconds |
| Timeout | 30 seconds per attempt |
| Success | Any `2xx` status |
| Failure | Any non-`2xx` status, a connection error, or a timeout |
| Ordering | **Not guaranteed.** Deliveries are queued jobs and can arrive out of order. |
| Deduplication | **None.** The same event can arrive more than once. |

Return `2xx` as soon as you have durably accepted the payload, then do your real work asynchronously. Anything slower than 30 seconds is recorded as a failure and retried, so a slow-but-successful handler produces duplicate deliveries.

### Retries

A failed delivery is retried automatically. There are **three delivery attempts in total**:

| Attempt | When |
|---------|------|
| 1 | Immediately after the event |
| 2 | ~5 minutes after attempt 1 fails |
| 3 | ~30 minutes after attempt 2 fails |

After the third failure the delivery is marked permanently failed and is never retried again. There is no exponential backoff beyond these fixed steps, no jitter, and no manual replay endpoint — a permanently failed delivery has to be reconciled by reading the API.

### Build your own idempotency

Since deliveries can duplicate, arrive out of order, and be replayed by anyone holding a captured request, your handler must be idempotent. A workable key is the pair (`_event`, the record id in the payload):

```
order.completed:1042
```

Record that key on first successful processing and short-circuit on any repeat. Do not key on `_timestamp` — it is regenerated on every retry attempt, so retries of the same event carry different timestamps.

---

## Delivery is gated on API access

Outgoing webhooks are part of the same **API & Webhooks** entitlement that gates the REST API. If a store's plan does not include it, or the store's subscription lapses:

- **No webhooks fire at all**, for any event.
- The webhook rows stay configured, still show as Active in the dashboard, and start delivering again the moment access is restored.
- Nothing is queued or replayed for the gap. Events that fired while access was suspended are lost.

If a store's deliveries stop with no failures in the logs, check billing before you check your endpoint. See [Authentication](/api/authentication#api-access-gate).

The three enhanced-tracking events are additionally gated on the Enhanced Shipment Tracking entitlement, and are skipped silently for stores without it — see [Webhooks](/integrations/webhooks) for the plan requirements.

---

## Logs and reconciliation

Every attempt is recorded with its response code, response body (truncated), attempt count and timestamps, viewable in the dashboard under **Settings → Webhooks → View Logs**. **Logs are deleted after 30 days**, and there is no API to read them.

Because deliveries can be permanently lost — a three-attempt failure, or a gap while API access was suspended — do not treat webhooks as your only source of truth. Pair them with a periodic reconciliation sweep:

```bash
# Catch anything the webhooks missed in the last hour.
curl "https://api.firearmcart.com/v1/orders?created_since=2026-08-11T14:00:00%2B00:00&per_page=100" \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json"
```

---

## Related documentation

- [Webhooks](/integrations/webhooks) — the event catalogue, plan requirements, and dashboard setup
- [Conventions](/api/conventions) — the id and money formats a payload carries
- [Authentication](/api/authentication) — the entitlement that gates delivery
- [Orders](/api/resources/orders) — reading the authoritative record after a webhook arrives

Source: https://docs.firearmcart.com/api/webhooks/index.mdx
