Skip to content

Webhooks

Consuming outgoing webhooks — payload shape, verification, and retry behaviour

Updated View as Markdown

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

{
  "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 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);
// 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:

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.

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.


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.

The three enhanced-tracking events are additionally gated on the Enhanced Shipment Tracking entitlement, and are skipped silently for stores without it — see 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:

# 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"

  • Webhooks — the event catalogue, plan requirements, and dashboard setup
  • Conventions — the id and money formats a payload carries
  • Authentication — the entitlement that gates delivery
  • Orders — reading the authoritative record after a webhook arrives
Navigation

Type to search…

↑↓ navigate↵ selectEsc close