Skip to main content

Webhooks

A webhook tells another system that something happened in your store. When an order is placed, a shipment is created or a product is updated, EverShop sends an HTTP POST request with a JSON body to a URL you choose, so your ERP, accounting tool, Slack bot or automation platform can react right away.

You set webhooks up in the admin panel, with no code. Each request is signed, failed requests are retried automatically, and every delivery is logged so you can see what was sent and what happened.

What it does​

  • Sends 21 store events to up to 10 URLs. Each webhook chooses which events it wants.
  • Signs every request with an HMAC-SHA256 signature so your server can verify that it came from your store.
  • Retries failed deliveries up to 5 times, then lets an admin retry by hand.
  • Keeps a delivery log per webhook, with the payload, attempts and result of each delivery.
  • Only talks to public HTTPS addresses by default, so a webhook cannot be used to reach your internal network.
  • Is extensible: extensions can add their own events to the list.

Set up a webhook​

  1. Go to Settings → Webhooks in the admin panel.
  2. Click New webhook.
  3. Fill in the form:
    • Name: a label for you, for example Order sync.
    • URL: where EverShop sends the requests. It must start with https://.
    • Status: enabled or disabled.
    • Events: tick the events to send. The list is grouped (Orders, Shipments, Products, Categories, Customers); open a group to see its events. A group shows how many of its events are selected.
  4. Click Save. You are taken to the edit page.
  5. On the edit page, copy the Signing secret. Your server needs it to verify requests.
  6. Click Send test event to check the URL and the secret. EverShop sends a webhook_ping event right away and shows the result.

The edit page also lists the webhook's Deliveries, newest first. You can filter by status, view the exact JSON that was sent, and retry failed deliveries.

info

You can create at most 10 webhooks. The grid shows each webhook's name, URL, number of events, status, and how many deliveries are pending or failed.

warning

Order, shipment and customer events contain personal data such as names, email addresses and addresses. Only send them to systems that are allowed to receive that data.

What EverShop sends​

Every delivery is an HTTP POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentEverShop-Webhook/1.0
X-EverShop-TopicThe event name, for example order_placed.
X-EverShop-DeliveryA unique ID for this delivery. It is the same on every retry, so use it to detect duplicates.
X-EverShop-SignatureThe signature, in the form t=1791424730,v1=f7a4b5f9.... See Verify the signature.

The body is an envelope around the event data:

Request body
{
"id": "6f1c0a9e-3f0b-4e55-9a57-0d9a6a3c1e10",
"event_id": "b7d5c3a4-81e2-4d0b-a3f6-5c2d9e7f4a18",
"topic": "order_status_updated",
"created_at": "2026-10-08T09:30:00.000Z",
"data": {
"orderId": 2070,
"before": "processing",
"after": "completed"
}
}
FieldDescription
idThe delivery ID. Same value as the X-EverShop-Delivery header, and the same on every retry.
event_idThe ID of the underlying EverShop event. null for a test event.
topicThe event name.
created_atWhen the delivery was queued (ISO 8601, UTC). Not the time of the attempt.
dataThe event data. The shape depends on the event; see Available events.

The body of a retry is exactly the same as the first attempt. Only the signature timestamp changes.

Verify the signature​

Anyone who knows your URL could send a fake request to it. The signature lets you prove a request really came from your store.

X-EverShop-Signature looks like t=1791424730,v1=f7a4b5f9...:

  • t is the Unix time (in seconds) when the request was signed.
  • v1 is the hex-encoded HMAC-SHA256 of the string <t>.<raw body>, using the webhook's signing secret as the key.

To verify a request:

  1. Read the raw body exactly as received. Do not parse and re-serialize it, because any change in whitespace or key order changes the signature.
  2. Split the header into t and v1.
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) and compare it with v1 in constant time.
  4. Reject the request if t is too old (five minutes is a good limit). This stops someone from replaying a request they captured.
Verify a webhook in Node.js (Express)
import crypto from 'crypto';
import express from 'express';

const SECRET = process.env.EVERSHOP_WEBHOOK_SECRET as string;
const TOLERANCE_SECONDS = 300;

function verifyEverShopSignature(
secret: string,
header: string | undefined,
rawBody: string
): boolean {
const parts = Object.fromEntries(
(header ?? '').split(',').map((part) => part.split('='))
);
const timestamp = Number(parts.t);
const received = parts.v1 ?? '';
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// express.raw keeps the body as the bytes that were signed.
app.post(
'/webhooks/evershop',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
if (
!verifyEverShopSignature(
SECRET,
req.header('X-EverShop-Signature'),
rawBody
)
) {
return res.sendStatus(400);
}

const deliveryId = req.header('X-EverShop-Delivery');
const event = JSON.parse(rawBody);
// 1. If deliveryId was already processed, return 200 and stop.
// 2. Otherwise save it, hand the work to a queue, and respond.
res.sendStatus(200);
}
);

The algorithm is plain HMAC-SHA256, so the same check works in any language.

Respond quickly, expect duplicates​

How your server answers decides what happens next:

  • Reply with a 2xx status within 10 seconds. That counts as delivered. The response body is ignored and never stored.
  • Anything else counts as a failure: a 4xx or 5xx status, a timeout, a connection or DNS error. Note that 4xx responses are retried too, not treated as final.
  • Redirects are not followed. A 301 or 302 answer is a failure. Put the final URL in the webhook.
  • Do slow work after you respond. Save the delivery, answer 200, then process it from a queue.

Once a delivery is queued, EverShop sends it at least once, not exactly once. In rare cases, for example when the server restarts in the middle of a request, the same delivery can arrive twice. Store the X-EverShop-Delivery value and ignore a delivery you have already handled.

Events are also not ordered. Two events that happen close together can arrive in either order, and a retried delivery arrives later than newer ones. If order matters, use a timestamp in the event data, such as updated_at, to decide which version is newer.

Retries​

If an attempt fails, the delivery is retried automatically. A background job checks for due retries every minute, so each retry can be up to a minute later than the times below.

AttemptWhen
1Immediately when the event happens
21 minute after attempt 1 failed
35 minutes after attempt 2 failed
430 minutes after attempt 3 failed
52 hours after attempt 4 failed

After the fifth failed attempt the delivery is marked Failed. Open the webhook, find the delivery and click Retry to give it another full round of 5 attempts. Retry is only available for failed deliveries of an enabled webhook.

If you disable a webhook, its pending deliveries are canceled for good (a canceled delivery cannot be retried), and events that happen while it is disabled are not queued. If your server is only down for a while, leave the webhook enabled instead: failed deliveries are kept and can be retried once the server is back.

Delivery log​

Each delivery is stored with its payload, so the edit page of a webhook doubles as a log.

StatusMeaning
PendingWaiting to be sent, or waiting for its next retry. The row shows the attempt count and the next try time.
SendingAn attempt is in progress.
DeliveredYour server answered with a 2xx status.
FailedAll 5 attempts failed. The last HTTP status or error is shown. Can be retried.
CanceledThe webhook was disabled before the delivery was sent.

How long entries are kept:

  • Delivered and canceled deliveries: the newest 100 per webhook. Change the number under Settings → Webhooks → Delivery log (between 10 and 1000).
  • Failed deliveries: 30 days, or until you retry them.
  • Pending and sending deliveries are never deleted, because they are waiting to be sent.

Deleting a webhook deletes its deliveries too.

info

Stored payloads contain the same personal data that was sent. They are removed by the rules above, so choose a log size that fits your privacy policy.

Available events​

These are the events an admin can choose. Each sends the same data as the matching event in Events and Subscribers, which shows example payloads.

GroupEventSent when / data
Ordersorder_placedAn order is placed: payment succeeded, or the payment method (such as Cash on Delivery) does not need it up front. Use this for "new order" notifications. Sends the order.
order_createdAn order record is created, whatever its payment state. For online payments this can happen before order_placed. Sends the order.
order_status_updatedAn order's status changes. Sends the order ID and the previous and new status.
order_canceledAn order is canceled. Sends the order ID and the reason, if one was given.
order_refundedAn order is refunded, fully or partly. Sends the order ID, amount, currency, whether it is a full refund, the transaction ID and the payment method.
Shipmentsshipment_createdA shipment is created. Sends the shipment ID, the order ID and whether the customer should be notified.
shipment_status_changedA shipment's status changes. Sends the shipment ID, order ID, previous and new status, and the phase.
shipment_deliveredA shipment reaches the delivered phase. Sends the shipment ID and the order ID.
shipment_label_createdA carrier label is purchased. Sends the shipment ID, order ID, label URL (can be empty) and tracking number.
shipment_label_voidedAn admin voids a shipping label. Sends the shipment ID, order ID and tracking number.
Productsproduct_createdA product is created. Sends the product.
product_updatedA product is updated. Sends the product.
product_deletedA product is deleted. Sends the deleted product.
inventory_updatedA product's inventory changes. Sends the inventory before and after. This event also fires when a product is saved without a stock change, so compare old and new.
Categoriescategory_createdA category is created. Sends the category.
category_updatedA category is updated. Sends the category.
category_deletedA category is deleted. Sends the deleted category.
Customerscustomer_createdA customer account is created, whatever its status. Sends the customer.
customer_registeredA customer account is created and active. Sends the customer.
customer_updatedA customer account is updated. Sends the customer.
customer_deletedA customer account is deleted. Sends the deleted customer.

Customer payloads never include the password. EverShop removes any field named password from every payload before it is stored or sent.

The test event​

Send test event sends a webhook_ping event:

Test event body
{
"id": "0b3f8f1e-2c64-4b8a-9a0e-7d1d6a4c5e22",
"event_id": null,
"topic": "webhook_ping",
"created_at": "2026-10-08T09:30:00.000Z",
"data": {
"message": "Test event from EverShop",
"webhook_uuid": "3c9a6a52-1f0d-4d3e-8c55-2b7e1a9d4f60"
}
}

It ignores the event selection, works even if the webhook is disabled, is not retried, and appears in the delivery list like any other delivery. The admin waits for the result, so the request can take up to 10 seconds.

Security​

HTTPS only. A webhook URL must start with https:// and must not contain a username or password.

Public addresses only. EverShop refuses to send a webhook to a private or reserved address: loopback (127.0.0.0/8, ::1), private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16, which includes the 169.254.169.254 cloud metadata address) and other reserved ranges, as well as localhost. This stops a webhook from being used to probe your internal network.

The check is made when the request is sent, on the address that is actually connected to, not only when you save the URL. A hostname that later resolves to an internal address is blocked as well. A blocked delivery fails with Blocked: the URL points to a private or reserved address.

Local development and private networks. To send webhooks to a service on your own network (a self-hosted n8n, an ERP, or http://localhost while you develop), turn the restriction off in your configuration. It is a server setting on purpose, not an admin panel option:

config/local.json
{
"system": {
"webhook": {
"allowPrivateNetworks": true
}
}
}

With it on, http:// URLs and private addresses are accepted. Leave it off in production unless you need it.

The signing secret. EverShop generates the secret (whsec_ followed by random characters) when you create the webhook. You cannot choose it. Click Regenerate on the edit page to replace it; the old secret stops working immediately, so update your server first or accept that deliveries fail until you do. Deliveries that are already queued are signed with the new secret.

Add your own events​

Developers can add events to the list in the admin panel. The event must already be emitted with emit. Register it in your extension's bootstrap.ts:

extensions/my-extension/bootstrap.ts
import { addProcessor } from '@evershop/evershop/lib/util/registry';

type WebhookTopic = {
name: string;
label?: string;
description?: string;
group?: string;
};

export default () => {
addProcessor('webhookTopics', (topics: WebhookTopic[]) => [
...topics,
{
name: 'my_custom_event',
label: 'My custom event',
description: 'Sent when ... Sends the ...',
group: 'My extension'
}
]);
};
FieldDescription
nameThe event name, exactly as passed to emit. Required.
labelThe name shown to the admin. Defaults to name.
descriptionOne or two sentences saying when it is sent and what data it carries. Say what the receiver gets, because some events carry only IDs.
groupThe section of the events list. Defaults to Other.

Registering a name that already exists replaces that entry, so an extension can reword a core event. Labels, descriptions and groups are English text that is translated at display time; add them to a translation file to translate them.

To strip more fields from every webhook payload, register extra field names. A key with that name is removed at any depth:

extensions/my-extension/bootstrap.ts
import { addProcessor } from '@evershop/evershop/lib/util/registry';

export default () => {
addProcessor('webhookRedactedFields', (fields: string[]) => [...fields, 'ssn']);
};
warning

Both lists must be fixed lists that do not depend on the event being processed. Do not compute them from event data.

Send every event to your own code​

Webhooks are built on a general feature of the event system: a subscriber folder named _all runs for every event. If you need the same data inside your own code instead of over HTTP, see Subscribing to every event.

REST API​

The admin panel uses these endpoints, and you can call them too. They are admin-only; see Authentication. :id is the UUID of the webhook (or of the delivery).

MethodPathDescription
POST/api/webhooksCreate a webhook. Returns it, including the generated secret.
PATCH/api/webhooks/:idUpdate any of name, url, topics, enabled. Send "regenerateSecret": true to replace the secret. Disabling cancels pending deliveries.
DELETE/api/webhooks/:idDelete a webhook and its deliveries.
POST/api/webhooks/:id/testSend a webhook_ping now. Returns deliveryUuid, status, statusCode and error.
POST/api/webhook-deliveries/:id/retryQueue a failed delivery for another round of attempts. It is sent within a minute.
POST /api/webhooks
{
"name": "Order sync",
"url": "https://example.com/webhooks/evershop",
"topics": ["order_placed", "order_status_updated"],
"enabled": true
}

Validation errors answer 400 with { "error": { "status": 400, "message": "..." } }, and an unknown webhook or delivery answers 404.

Troubleshooting​

What you seeWhy, and what to do
"Webhook URL must start with https://"Plain http:// is refused by default. Use HTTPS, or set system.webhook.allowPrivateNetworks for local development.
"Webhook URL points to a private or reserved address"The host is localhost or a private IP. Use a public address, or set system.webhook.allowPrivateNetworks.
Delivery fails with "Blocked: the URL points to a private or reserved address"The hostname resolves to a private address. Same fix as above.
Nothing arrives after creating a webhookCheck the webhook is Enabled and the event is ticked. A new or changed webhook can take up to 10 seconds to take effect. Use Send test event to isolate the URL from the events.
Deliveries stay PendingRetries are sent by a background job that runs every minute in the cron process. Make sure the store was started with npm run start (or npm run dev), which starts it.
Failed with "Timed out after 10 seconds"Your server did not answer in time. Respond first, process afterwards.
Failed with an HTTP 3xxRedirects are not followed. Use the final URL.
Failed with an HTTP 4xxYour server rejected the request, often a signature check. Compare the secret, and verify the raw body. 4xx responses are retried, so fix the server and use Retry.
The same delivery arrived twiceDelivery is at-least-once. Ignore a repeated X-EverShop-Delivery value.
Retry button is missingOnly Failed deliveries can be retried, and only while the webhook is enabled.
You added an event to your extension but it does not appearRegister it in bootstrap.ts (see Add your own events) and restart the store.

How it works​

For developers who want to know what happens behind the scenes:

  1. When EverShop emits an event, a subscriber (the _all wildcard) checks whether any enabled webhook wants it. If none does, it returns immediately and costs almost nothing.
  2. For each matching webhook it writes a delivery row first, then makes the first attempt. From that moment the row, not the event, is the source of truth.
  3. A cron job named webhookProcessDeliveries runs every minute. It sends due retries, recovers deliveries that were interrupted by a crash, cancels deliveries of disabled webhooks, and trims the log.

This differs from the normal event system, where an event is handled once and never retried. Writing the row first is what makes retries and the delivery log possible. See also Cron Jobs.

LimitValue
Webhooks10
Attempts per delivery5 (1 immediate and 4 retries)
Request timeout10 seconds
Pending deliveries per webhook10,000. Above that, new events for that webhook are skipped (and a warning is logged) until it catches up.
Webhook name / URL length255 / 2048 characters