# Receive webhooks

> Subscribe a URL to YoPrint events, verify the X-Signature on each delivery, answer 200, and handle retries and duplicates.

Web page: https://developer.yoprint.com/guides/webhooks/

A webhook subscription tells YoPrint to `POST` an event to your URL whenever something happens on the team, such as a sales order being created or a payment arriving. Events fire for changes made in the web app, through the API and by AI apps alike.

**Diagram:** No 2xx answer in time? YoPrint tries twice more, 10 and then 100 seconds later, and then gives up on that event.

1. **Something happens**: An order is created, a payment arrives…
2. **YoPrint POSTs the event**: About 10 seconds later, signed with your secret
3. **You check and answer**: Verify the signature, then answer `200`
4. **Handle it once**: Use the event's `id`: a retry can repeat it

You need a role that can manage settings to create, change or delete subscriptions.

Webhooks need a plan with API access (Pro or higher). See [If the team's plan changes](#if-the-teams-plan-changes).

## 1. Build the endpoint

Your endpoint receives a `POST` with a JSON body:

```json
{
  "id": "V4keXwwxMQyXw7",
  "type": "order.created",
  "payload": { "id": "VwYKuTP9vd8Dfo", "scoped_id": "SO1001" },
  "created_at": "2026-11-03T15:00:00+00:00",
  "updated_at": "2026-11-03T15:00:00+00:00"
}
```

- `id` identifies the event. Retries of the same event carry the same `id`.
- `type` is the event name.
- `payload` is the record the event is about. Its fields depend on the event. The example above is shortened.

The request has these headers:

| Header         | Value                                  |
| -------------- | -------------------------------------- |
| `Content-Type` | `application/json`                     |
| `User-Agent`   | `YoPrint Webhooks Agent`               |
| `X-Signature`  | The signature of the body. See step 4. |

Answer within 3 seconds with status `200`. Do the real work afterwards, for example on a queue.

## 2. Create the subscription

`POST /v1/api/store/{team_slug}/setting/webhook_subscription` [Create a webhook subscription](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-store.md)

```bash
curl https://secure.yoprint.com/v1/api/store/your-team/setting/webhook_subscription \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json" \
  -H "X-Yoprint-Decimal-Format: exact-v1" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order sync",
    "url": "https://example.com/yoprint/webhooks",
    "enabled_events": ["order.created", "payment_received.any_amount"],
    "source": "yoprint"
  }'
```

- `name`, `url`, `enabled_events` and `source` are required. Send `"source": "yoprint"`. The value `zapier` is for YoPrint's Zapier app.
- `url` must be a public `https://` address. See [Which URLs YoPrint accepts](#which-urls-yoprint-accepts).
- `enabled_events` lists [event names](#events). The single value `all` subscribes to every event.

The response includes the subscription's `id` and its `secret`, which starts with `whsec_`. YoPrint makes the secret, and you can't choose it. Store it with your other secrets. Reading a subscription later shows the secret only to the team owner and people who can manage settings. For anyone else, `secret` is `null`.

You can also create subscriptions in the web app, under **Webhooks** in settings.

### Which URLs YoPrint accepts

YoPrint posts to your URL from its own servers, so it accepts only addresses on the public internet. Creating a subscription, or changing its URL, is refused with `422` `validation_failed` when:

| The URL                                                                                                       | Message, in `error.fields.url`                                                                   | `error.field_reasons.url.reason` |
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------- |
| isn't `https://`, or its host is, or resolves to, a private, loopback, link-local or other non-public address | "Use an https\:// URL that reaches a public internet address."                                   | `not_public_address`             |
| has a host name that couldn't be looked up                                                                    | "The host name in this URL could not be looked up. Check the address, or try again in a minute." | `host_lookup_failed`             |

A failed lookup can be temporary: check the address, then try again. YoPrint checks the address again just before each delivery, and doesn't send to one that no longer reaches a public address.

## 3. See what an event looks like

`GET /v1/api/store/{team_slug}/setting/webhook_subscription/sample/{event_name}` [Show a sample event](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-sample.md)

```bash
curl https://secure.yoprint.com/v1/api/store/your-team/setting/webhook_subscription/sample/order.created \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json" \
  -H "X-Yoprint-Decimal-Format: exact-v1"
```

The sample is built from your team's latest matching record, so it shows the real shape of `payload`. It comes back as the bare event, without the usual `status`, `success` and `data` wrapper. On a team with no matching records, it's empty.

## 4. Verify the signature

`X-Signature` is the HMAC-SHA256 of the raw request body, keyed with the subscription's secret, as lowercase hex. Check it before you trust a delivery.

1. Read the raw body bytes, before any JSON parsing.
2. Compute HMAC-SHA256 of those bytes, keyed with the whole secret, `whsec_` prefix included, and hex-encode it.
3. Compare it with `X-Signature` using a constant-time comparison. Reject the request if they differ.

A complete receiver checks the signature, answers `200`, and skips events it has already handled. Each one reads the secret from the `YOPRINT_WEBHOOK_SECRET` environment variable.

**JavaScript**

Node.js 18 or later, with Express.

```javascript
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.YOPRINT_WEBHOOK_SECRET; // the whole secret, whsec_ included
const handled = new Set(); // keep these in your database in production

const app = express();

// express.raw keeps the body as bytes, so the signature can be checked.
app.post('/yoprint/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Signature') ?? '';
  const expected = createHmac('sha256', SECRET).update(req.body).digest('hex');
  if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString('utf8'));
  res.status(200).end(); // answer first: YoPrint waits 3 seconds at most
  if (handled.has(event.id)) return;
  handled.add(event.id);
  setImmediate(() => handle(event));
});

function handle(event) {
  console.log(event.type, event.payload.id);
}

app.listen(3000);
```

**Python**

Python 3 with Flask 2 or later.

```python
import hashlib
import hmac
import json
import os

from flask import Flask, request

SECRET = os.environ["YOPRINT_WEBHOOK_SECRET"].encode()  # the whole secret, whsec_ included
handled = set()  # keep these in your database in production

app = Flask(__name__)


@app.post("/yoprint/webhooks")
def yoprint_webhook():
    body = request.get_data()  # the raw bytes, before any JSON parsing
    expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Signature", "")):
        return "", 401
    event = json.loads(body)
    if event["id"] not in handled:
        handled.add(event["id"])
        handle(event)  # keep this fast, or put the event on a queue
    return "", 200


def handle(event):
    print(event["type"], event["payload"]["id"])
```

**PHP**

Plain PHP 7.4 or later.

```php
<?php
$secret = getenv('YOPRINT_WEBHOOK_SECRET'); // the whole secret, whsec_ included
$body = file_get_contents('php://input');   // the raw bytes, before any JSON parsing
$expected = hash_hmac('sha256', $body, $secret);
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

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

$event = json_decode($body, true);
http_response_code(200);

// Skip $event['id'] if you've handled it already (keep the ids in your database).
// Otherwise put the event on a queue, and return: YoPrint waits 3 seconds at most.
```

> **Caution:** Sign the raw bytes you received. Parsing the JSON and serializing it again changes the bytes (YoPrint escapes `/` as `\/`, for example), and the signature won't match.

The signature has no timestamp. To guard against a replayed delivery, remember the event `id`s you've processed and ignore repeats.

## 5. Handle retries and duplicates

YoPrint sends each event about 10 seconds after it happens. A delivery succeeds when your endpoint answers `2xx` in time. Otherwise YoPrint tries again:

| Attempt | When                               |
| ------- | ---------------------------------- |
| 1       | About 10 seconds after the event   |
| 2       | 10 seconds after attempt 1 failed  |
| 3       | 100 seconds after attempt 2 failed |

After the third failed attempt, YoPrint gives up on that event.

Because of retries, your endpoint can get the same event more than once. Use `id` to process each event once.

## 6. Check deliveries

`GET /v1/api/store/{team_slug}/setting/webhook_subscription/{id}/log` [List deliveries for a webhook subscription](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-log.md)

The log lists deliveries newest first, with the attempt number, the status code your endpoint returned, and the event itself. For a failed delivery, `error` says only what YoPrint knows, such as "The receiver answered with HTTP 500." or that it couldn't reach the receiver (a connection, DNS, TLS or timeout error). YoPrint doesn't keep or show what your endpoint answered, so log failures on your side to debug a receiver.

YoPrint can turn off a subscription whose deliveries keep failing. It then sets `status` to `disabled` and `disable_reason` to `auto_disable_too_many_errors`, and emails the team owner. YoPrint doesn't promise when it checks, so don't rely on it to stop a broken receiver: pause the subscription yourself.

Read a subscription to check its `status` and `disable_reason`:

`GET /v1/api/store/{team_slug}/setting/webhook_subscription/{hash_id}` [Show a webhook subscription](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-show.md)

Turn it back on, change its URL or events, or pause it with `"status": "disabled"`. Send `url` with every update, even when it doesn't change: an unchanged URL isn't checked again, so a subscription whose receiver is broken can still be paused.

`PUT /v1/api/store/{team_slug}/setting/webhook_subscription/{hash_id}` [Update a webhook subscription](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-update-put.md)

To stop for good, delete it. YoPrint then sends one `DELETE` request to the subscription's URL.

`DELETE /v1/api/store/{team_slug}/setting/webhook_subscription/{hash_id}` [Delete a webhook subscription](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-destroy.md)

## Test a delivery

There's no button to send a test event. To get a real delivery without touching real orders:

1. Subscribe a test URL to `order.created` and `order.deleted`.
2. Create a draft sales order for a test customer, add a line, and publish it. That sends `order.created`.
3. Delete the order. That sends `order.deleted`.
4. Read the delivery log to see what your endpoint answered.

To see an event's shape without sending anything, use Show a sample event (step 3). See [Test your integration](https://developer.yoprint.com/start/testing.md).

## If the team's plan changes

Webhooks need a plan with API access (Pro or higher), with an active subscription or trial. While a team doesn't have it:

- YoPrint sends no deliveries. Events from that time are never sent later, and the delivery log shows nothing for them.
- Every API request is refused with `403` `api_access_not_in_plan`, including listing, changing and deleting subscriptions. Zapier can't remove its subscription when someone turns a Zap off.
- In YoPrint's settings, the team can still list and delete its subscriptions. The settings menu hides the **Webhooks** link, but the page still opens from a direct link.

Subscriptions are kept, with their URLs and secrets, and deliver again once the team is back on a plan with API access.

## Only some status changes

The status-change events can be narrowed to some statuses. Add the status ids under `config` when you create the subscription:

```json
{
  "name": "Ready to ship",
  "url": "https://example.com/yoprint/webhooks",
  "enabled_events": ["job.status_changed"],
  "source": "yoprint",
  "config": {
    "job_status_changed_options": { "status_ids": ["BkzhmfgkK2HFTs"] }
  }
}
```

The options are `job_status_changed_options`, `sales_order_status_changed_options`, `quote_status_changed_options`, `shipment_status_changed_options`, `purchase_order_status_changed_options`, `work_pipeline_status_changed_options` and `message_status_changed_options`. Get status ids from the status lists, such as:

`GET /v1/api/store/{team_slug}/setting/status/job` [List job statuses](https://developer.yoprint.com/reference/statuses/v1-status-list-job.md)

## Events

| Area                    | Events                                                                                                                                                           |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Customers               | `customer.created`, `customer.updated`, `customer.deleted`                                                                                                       |
| Contacts                | `customer_contact.created`, `customer_contact.updated`, `customer_contact.deleted`                                                                               |
| Customer credit         | `customer_credit.deposited`, `customer_credit.withdrew`                                                                                                          |
| Sales orders            | `order.created`, `order.deleted`, `order.restored`, `order.converted_to_quote`, `order.marked_as_rush`, `order.marked_as_not_rush`, `sales_order.status_changed` |
| Quotes                  | `quote.created`, `quote.deleted`, `quote.restored`, `quote.converted_to_sales_order`, `quote.status_changed`                                                     |
| Approvals               | `order_approval.approved`, `order_approval.change_requested`, `job_approval.approved`, `job_approval.change_requested`                                           |
| Jobs and tasks          | `job.status_changed`, `job.marked_as_rush`, `job.marked_as_not_rush`, `work_pipeline.status_changed`                                                             |
| Payments                | `payment_received.any_amount`, `payment_received.deposit_amount`, `payment_received.full_amount`, `payment.refund_issued`                                        |
| Shipping and purchasing | `shipment.status_changed`, `purchase_order.status_changed`                                                                                                       |
| Messages                | `message.received_customer`, `message.customer_bounced`, `message.received_purchase_order`, `message.bounced_purchase_order`, `message.status_changed`           |

- `order.created` and `quote.created` fire when the order or quote is published, not when the draft is made.
- In payment events (`payment_received.*`), `payload.amount` is a formatted string for display (such as `"$125.00"`), not a number. Don't parse it. For exact values, read the order: `paid_amount` is in thousandths, and totals are decimal pairs (`order_item_cart_total`, `order_balance_due`, `deposit_amount_due`). See [Money and quantities](https://developer.yoprint.com/concepts/money-and-quantities.md).

```json
{
  "id": "VwYKuTP9vd8Dfo",
  "type": "payment_received.any_amount",
  "payload": {
    "id": "p8Eq3vCAHNqhaB",
    "amount": "$125.00",
    "action": "MANUAL PAYMENT",
    "payment_method_name": "Credit Card",
    "transact_at": null,
    "order": {
      "id": "V4keXwwxMQyXw7",
      "scoped_id": "SO1001",
      "paid_amount": 125000
    }
  },
  "created_at": "2026-11-03T15:00:00+00:00",
  "updated_at": "2026-11-03T15:00:00+00:00"
}
```

- In customer credit events (`customer_credit.*`), `payload.amount` is also a formatted string (such as `"$50.00"`). For exact funds, read the customer (`wallet_fund` is in thousandths).
- Which payment event to listen to:

  | To know when…           | Listen to                      | Then                                                                                                                                                                                          |
  | ----------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | Any payment arrives     | `payment_received.any_amount`  | It fires for every payment.                                                                                                                                                                   |
  | The order is fully paid | `payment_received.full_amount` | It fires when nothing is left to pay.                                                                                                                                                         |
  | The deposit is paid     | `payment_received.any_amount`  | Read the order's `deposit_amount_due`: 0 or less means paid. `payment_received.deposit_amount` fires only when the total paid is **exactly** the deposit, so a bigger payment never sends it. |
- `order.status_changed` is an older event for both sales orders and quotes, sent **as well as** `sales_order.status_changed` or `quote.status_changed`. A subscription to `all` gets two events for one status change. Use the newer two.

## What never fires

Some changes have no event, so don't wait for one:

- Invoices: there are no invoice events.
- Creating a shipment or a job: only their status changes fire.
- Products, services and pricing: no events at all.

To follow these, read the records after a related event, or on a schedule.

## Pitfalls

- **Webhook amounts are for display.** The `amount` in payment and customer credit webhooks is formatted text (such as `"$125.00"`). To do arithmetic or read exact numbers, fetch the order or customer. See [Money and quantities](https://developer.yoprint.com/concepts/money-and-quantities.md).
- **Public `https://` URLs only.** A local, private or plain `http://` address is refused. To try webhooks from your own machine, use a tunnel with a public `https://` address.
- **Event names aren't checked.** A misspelled event is saved and never fires. Copy names from the table above.
- **Answer exactly `200`.** Other `2xx` codes stop the retries, but the delivery log records them as errors, and repeated errors can disable the subscription.
- **No redirects.** YoPrint doesn't follow them, so a URL that redirects (from `http` to `https`, or to add a trailing slash) fails every time. Use the final URL.
- **3 seconds.** Slow endpoints time out and get retried. Acknowledge first, work later.
- **Drafts are quiet.** `order.created` and `quote.created` come at publish, not before. Deleting a draft still fires `order.deleted` or `quote.deleted`.
