> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shoply.gg/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Be told when something happens to your store: what is sent, how to check it, and what happens when your server is down

A webhook is Shoply telling your server that something happened: an order was paid, a product ran low, an affiliate asked for a payout. You give Shoply an address and say which events you want, and each one is posted to that address as it happens.

## Adding one

In the dashboard, open **Developers**, then **Webhooks**, and press **Add Webhook**. Enter the address, tick the [events](/api-reference/webhook-events.md), and save. Or [add one with the API](/api-reference/webhook-endpoints/create-a-webhook-endpoint.md).

You are shown the endpoint's **signing secret** once. It starts `shoply_whsec_`. Keep it: your server needs it to check that a delivery is Shoply's. A store holds up to 20 endpoints.

The address has to be `https`, on port 443, 80, 8080 or 8443, and reachable from the internet. An address inside a private network is refused.

## What is sent

```http theme={null}
POST /webhooks/shoply HTTP/1.1
Content-Type: application/json
User-Agent: Shoply-Webhooks/1.0
X-Shoply-Event: order.paid
X-Shoply-Delivery: 6f1d2c0e-8a43-4f6b-9d1e-0c7b6a5e4d3c
X-Shoply-Timestamp: 1791705953
X-Shoply-Signature: v1=5f2a...c91e

{
  "id": "0b8f9f39-6d0f-4c6b-8f5e-2a8f2f8f3c11",
  "event": "order.paid",
  "createdAt": "2026-10-11T08:15:44Z",
  "storeId": "8d0a0f6e-3f0b-4c57-9a55-0c1f3f5d7a10",
  "data": { "id": "c1c7f7a0-55f4-4a37-8f43-3d2a8f0f6e12", "status": "completed", "email": "ada@example.com" }
}
```

| | |
| - | - |
| `id` | The event's ID. The same in every endpoint's delivery of it. |
| `event` | What happened. Also in `X-Shoply-Event`. |
| `createdAt` | When it happened. |
| `storeId` | The store it happened to. |
| `data` | The thing it happened to, as the API answers it: for an order's events, the order. |

`X-Shoply-Delivery` is this delivery's ID. It is the same on every retry and every resend of it, which makes it the thing to remember in order to take a delivery once.

## Checking the signature

Anybody can post to your address. Only Shoply knows your endpoint's secret, so check every delivery before you believe it.

The signature is `v1=` followed by the HMAC-SHA256, in lowercase hex, of the timestamp, a dot, and the body exactly as it arrived, under your secret:

```
v1=HMAC-SHA256(secret, "<X-Shoply-Timestamp>.<raw body>")
```

Three things to get right:

* **Use the raw body**, the bytes as they arrived. Parsing the JSON and writing it out again changes them, and the signature will never match.
* **Compare in constant time**, with the function your language has for it.
* **Refuse an old timestamp.** The timestamp is signed, so a delivery somebody captured cannot be sent to you again later with a new one. Five minutes is a good allowance.

With [@shoplygg/node](/api-reference/node.md#webhooks), `webhooks.verify(body, headers, secret)` does all three and answers the event, with [shoplygg/shoply-php](/api-reference/php.md#webhooks), `Shoply\Webhooks::verify($body, $headers, $secret)`, and with [shoply](/api-reference/python.md#webhooks) for Python, `shoply.webhooks.verify(body, headers, secret)`. Without them:

```javascript Node.js theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();

// The body as bytes: the signature is over exactly what was sent.
app.post("/webhooks/shoply", express.raw({ type: "application/json" }), (request, response) => {
  const timestamp = request.get("X-Shoply-Timestamp") ?? "";
  const signature = request.get("X-Shoply-Signature") ?? "";

  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", process.env.SHOPLY_WEBHOOK_SECRET)
      .update(`${timestamp}.`)
      .update(request.body)
      .digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
  const genuine =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!fresh || !genuine) {
    return response.status(401).send("Bad signature");
  }

  const event = JSON.parse(request.body);

  // Answer first, then do the work.
  response.status(200).send("ok");

  handle(event, request.get("X-Shoply-Delivery"));
});
```

```python Python theme={null}
import hashlib, hmac, os, time
from flask import Flask, abort, request

app = Flask(__name__)

@app.post("/webhooks/shoply")
def shoply():
    timestamp = request.headers.get("X-Shoply-Timestamp", "")
    signature = request.headers.get("X-Shoply-Signature", "")
    body = request.get_data()  # the bytes as they arrived

    expected = "v1=" + hmac.new(
        os.environ["SHOPLY_WEBHOOK_SECRET"].encode(),
        timestamp.encode() + b"." + body,
        hashlib.sha256,
    ).hexdigest()

    fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) <= 300

    if not fresh or not hmac.compare_digest(signature, expected):
        abort(401)

    handle(request.get_json(), request.headers.get("X-Shoply-Delivery"))

    return "ok"
```

```php PHP theme={null}
<?php

$body      = file_get_contents('php://input'); // the bytes as they arrived
$timestamp = $_SERVER['HTTP_X_SHOPLY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SHOPLY_SIGNATURE'] ?? '';

$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('SHOPLY_WEBHOOK_SECRET'));

$fresh = ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300;

if (!$fresh || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Bad signature');
}

$event = json_decode($body, true);

handle($event, $_SERVER['HTTP_X_SHOPLY_DELIVERY'] ?? '');

echo 'ok';
```

```go Go theme={null}
func shoply(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body) // the bytes as they arrived

	timestamp := r.Header.Get("X-Shoply-Timestamp")
	signature := r.Header.Get("X-Shoply-Signature")

	mac := hmac.New(sha256.New, []byte(os.Getenv("SHOPLY_WEBHOOK_SECRET")))
	mac.Write([]byte(timestamp + "."))
	mac.Write(body)

	expected := "v1=" + hex.EncodeToString(mac.Sum(nil))

	sent, err := strconv.ParseInt(timestamp, 10, 64)
	fresh := err == nil && math.Abs(float64(time.Now().Unix()-sent)) <= 300

	if !fresh || !hmac.Equal([]byte(signature), []byte(expected)) {
		http.Error(w, "Bad signature", http.StatusUnauthorized)
		return
	}

	w.Write([]byte("ok"))
}
```

> [!NOTE]
>
> [Dynamic delivery](/guides/dynamic-delivery.md) is signed the same way, under the same two headers, with the store's delivery signing secret. One function checks both.

## Answering

Answer with any `2xx` status and the delivery is taken. What you write in the body does not matter, though its first 2,000 characters are kept in the delivery log for you to read.

**Answer within five seconds.** Take the delivery, answer, and do the work afterwards. A server that takes longer is counted as not having answered.

A redirect is not followed. If your address moves, save the new one on the endpoint.

## When your server does not take it

A delivery that is not taken is tried again, eight times in all, at gaps that widen:

| Try | After the one before |
| - | - |
| 1 | At once |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 3 hours |
| 7 | 6 hours |
| 8 | 12 hours |

That is a little over 22 hours from first to last, so a server that was down overnight still gets what it missed. After the eighth try the delivery is marked `failed` and left. One that your server has still not taken 24 hours after its event is marked `failed` too, tried eight times or not.

Every try is written down. The delivery log, in the dashboard and [in the API](/api-reference/webhook-deliveries/list-webhook-deliveries.md), shows the status your server answered with and the start of what it said, or why it could not be reached: it did not answer in time, it refused the connection, its certificate is wrong. **Resend** on any delivery sends the same body again, under the same delivery ID, signed afresh.

### An endpoint that keeps failing

One delivery that is not taken is treated as a bad moment, and nothing else changes. From the second in a row the endpoint is held as a whole: nothing waiting for it is sent, and an event that happens meanwhile waits with the rest. When the hold is over, one delivery is tried. If your server takes it, everything that was waiting untried is sent at once. If it does not, the endpoint is held again:

| Refused in a row | Nothing is sent for |
| - | - |
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 5 minutes |
| 5 | 15 minutes |
| 6 | 30 minutes |
| 7 or more | 1 hour |

What counts as one more in the row is a delivery refused for the first time, or a server that gives no answer at all. A delivery your server has refused before and answers with an error again does not make the hold longer, so one event that your handler cannot take does not hold up the others for an hour. The count starts again when your server takes a delivery, or after a day with nothing refused.

So a server that is down is tried once an hour at most, however many events it has missed, and is sent them within the hour of coming back. To have them sooner, press **Send a test** on the endpoint: a test that your server takes ends the hold. Saving the endpoint with a new address has one delivery tried at once.

Waiting spends none of a delivery's eight tries, and a delivery your server has already refused keeps to its own times in the table above.

If the trouble is on Shoply's side, nothing is held against your endpoint and no try is spent: the delivery waits a minute and is sent then. Its row in the log says so.

When 20 deliveries in a row have been given up on, the endpoint is **paused**: nothing more is sent to it, and you are shown so in the dashboard. Fix your server and switch the endpoint back on. Deliveries that were already waiting for it are sent then. What happened while it was paused was not kept for it, so read it back from the API.

### How fast they are sent

A delivery is sent as its event happens. An endpoint is sent its deliveries one at a time: the next is sent when the one before it has been answered. So answer quickly, and do the work afterwards.

A server that takes two seconds or more to answer is sent its deliveries from a queue for the next ten minutes, and not as each event happens. The queue runs every minute, so they can arrive up to a minute late.

A store sends one server at most 1,200 deliveries a minute, whichever of the store's endpoints they are for. The server is counted by the addresses its name points at, so several names for one machine are one server. Past it no try is spent: a delivery waits a few seconds and is sent then.

### How much can wait

Up to 5,000 deliveries wait for one endpoint. While that many are waiting, a new event is not queued for it, and events are queued again as soon as fewer are. An endpoint that takes what it is sent has nothing waiting, so this is only ever reached by one that has refused for hours in a busy store. Read what it missed back from the API.

The delivery log keeps 30 days, and a store's newest 50,000 deliveries that were taken or given up on. One that is still waiting is never removed from it.

## Getting it right

> **Expect a delivery more than once**
>
> A retry after your server took a delivery but answered too slowly, or a resend, brings the same delivery again. Remember `X-Shoply-Delivery` and do nothing the second time.

> **Do not rely on the order they arrive in**
>
> Deliveries are sent as events happen, and a retry can land after a later event. Go by `createdAt`, or read the thing back from the API.

> **A webhook tells a server, not a page**
>
> The Checkout Kit and the embed tell the page they are on that an order was paid, so that the page can say thank you. That is not proof of anything. Hand over goods of your own when your server receives `order.paid`.

> **Trust the signature, not the address**
>
> Deliveries do not come from one fixed IP address, and the addresses they do come from are shared with other senders. Do not allowlist an address in place of checking the signature: the signature is what says a delivery is Shoply's.

> **Check up now and then**
>
> Servers go down for longer than a day. Read [orders](/api-reference/orders/list-orders.md) back now and then to catch what was missed.

## Trying it

**Send a test** on an endpoint posts a `webhook.test` event to it at once, signed like any other, and shows you what your server answered. It is sent whatever the endpoint subscribes to, and whether or not the endpoint is being held.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.