# Create a webhook subscription

```http
POST https://secure.yoprint.com/v1/api/store/{team_slug}/setting/webhook_subscription
```

- API version: v1
- Operation ID: `v1_public_webhook_subscription_store`
- Section: Team settings › [Webhook subscriptions](https://developer.yoprint.com/reference/webhook-subscriptions.md)
- Web page: https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-store/
- Authentication: `Authorization: Bearer YOUR_API_KEY`

Subscribes a URL to team events. The subscription starts `active`, and YoPrint generates its signing `secret` and returns it in the response.

Events fire for changes made in the web app, through the API and by AI apps alike. A change that triggers several events sends each of them.

## What YoPrint sends

About 10 seconds after an event, YoPrint sends one `POST` to the `url` of every active subscription whose `enabled_events` holds the event name or `all`.

Headers:

- `Content-Type: application/json`
- `User-Agent: YoPrint Webhooks Agent`
- `X-Signature`: the HMAC-SHA256 of the raw request body, keyed with the subscription's `secret`, as 64 lowercase hex characters.

Body:

```json
{
  "id": "VwYKuTP9vd8Dfo",
  "type": "order.created",
  "payload": {
    "id": "BkzhmfgkK2HFTs",
    "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 an event carry the same `id`. `payload` is the record the event is about (see "Events" below).

### Verify the signature

1. Read the raw body bytes before any JSON parsing.
2. Compute HMAC-SHA256 of those bytes with the secret (the whole string, `whsec_` included) and hex-encode it.
3. Compare the result with `X-Signature` in constant time, for example `hash_equals(hash_hmac('sha256', $rawBody, $secret), $signature)` in PHP or `crypto.timingSafeEqual` in Node.js. Reject the request when they differ.

Don't parse and re-serialize the JSON before checking: YoPrint's JSON escapes `/` as `\/` and non-ASCII characters as `\uXXXX`, so the bytes change. The signature has no timestamp, so remember the event ids you have processed and ignore repeats.

### Answer, retries and timeouts

- Answer with status `200` within 3 seconds. Do slow work afterwards.
- YoPrint checks the TLS certificate of `https` URLs and does not follow redirects: a `3xx` answer is a failure.
- Before every attempt YoPrint looks up the URL's host again. If it doesn't resolve, or any address it resolves to is not a public internet address (private, loopback, link-local, carrier-grade NAT, multicast or reserved), nothing is sent and the attempt fails with `status_code` `999`. YoPrint connects only to the addresses it checked, never through a proxy.
- A non-`2xx` status, a timeout or a connection error fails the attempt. YoPrint tries an event up to 3 times: once, again 10 seconds after a failure, and a last time 100 seconds after the second failure. Then it gives up on that event.
- Any `2xx` stops the retries, but only `200` counts as a success in the delivery log. Other `2xx` codes show as `error`.
- 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 does not promise when it checks, so pause a broken receiver yourself by setting `status` to `disabled`.
- A team whose plan no longer includes API access gets no deliveries, and no delivery rows are logged. Subscriptions are kept and deliver again after an upgrade. Every API request then answers 403 `api_access_not_in_plan`, listing and deleting included. In YoPrint's settings the team can still list and delete its subscriptions.
- Each delivery is sent on its own, so events can arrive out of order, for example after a retry.
- For a subscription with `source` `zapier`, a `410 Gone` answer stops the retries at once.

## Events

### Sales orders and quotes

- `order.created`: a sales order is published (it leaves draft). Nothing is sent for drafts. Payload: the sales order.
- `quote.created`: a quote is published. Payload: the quote.
- `order.deleted`, `quote.deleted`: a sales order or quote is deleted. Payload: the order or quote, with `trashed: true`.
- `order.restored`, `quote.restored`: a deleted sales order or quote is restored. Payload: the order or quote.
- `quote.converted_to_sales_order`: a quote is converted to a sales order. Payload: the converted document.
- `order.converted_to_quote`: a sales order is converted to a quote. Payload: the converted document.
- `order.marked_as_rush`, `order.marked_as_not_rush`: a sales order's rush flag is turned on or off. Payload: the sales order.
- `sales_order.status_changed`: a sales order moves to a different status. Payload: the sales order, plus `previous_status_id`, `previous_status` and `participant_emails` (the message participants who receive email).
- `quote.status_changed`: the same, for a quote.
- `order.status_changed`: either of the two above. It is an older event kept for Zapier and sent alongside them, so a subscriber to `all` gets two events for one status change. Payload: the same.

### Jobs, tasks, shipments and purchase orders

- `job.status_changed`: a job moves to a different status. Payload: the job, plus `previous_status_id`, `previous_status` and `participant_emails`.
- `job.marked_as_rush`, `job.marked_as_not_rush`: a job update turns its rush flag on or off. Payload: the job.
- `work_pipeline.status_changed`: a task moves to a different status. Payload: the task, plus `previous_status_id` and `previous_status`.
- `shipment.status_changed`: a shipment's status is changed. Payload: the shipment, plus `previous_status_id` and `previous_status`.
- `purchase_order.status_changed`: a purchase order's status is set, even to the status it already has. Payload: the purchase order, plus `previous_status_id`, `previous_status` and `related_order` (its linked sales orders, or null).

### Approvals

- `order_approval.approved`, `order_approval.change_requested`: an order approval is completed, approved or with changes requested. Payload: the approval, with its `order` and `customer`.
- `job_approval.approved`, `job_approval.change_requested`: a job approval is completed. Payload: the approval, with its `order`, `job` and `customer`.

### Payments and customer credit

- `payment_received.any_amount`: any payment is recorded on a sales order: a manual payment, a gateway or payment-link payment, or a payment from customer credit. Payload: the payment, with its `order`.
- `payment_received.deposit_amount`: after a payment, the order's total paid equals its deposit amount (`calc_deposit_total`) exactly. Sent as well as `any_amount`. Not sent when a payment goes past the deposit, for example a first payment of the full amount. Payload: the same.
- `payment_received.full_amount`: after a payment, the total paid equals the invoiced total exactly (the balance due is zero). Sent as well as `any_amount`. Not sent for an overpayment, or while the order has no invoice. Payload: the same.
- `payment.refund_issued`: a refund is recorded on a sales order. Payload: the refund, with its `order`.
- `customer_credit.deposited`, `customer_credit.withdrew`: credit is added to or taken from a customer's credit balance. Payload: the transaction, with its `customer`.

### Customers and contacts

- `customer.created`, `customer.updated`, `customer.deleted`: a customer is created, changed or deleted. Payload: the customer, with its `contacts`.
- `customer_contact.created`, `customer_contact.updated`, `customer_contact.deleted`: a customer contact is created, changed or deleted. Payload: the contact, with its `customer`.

### Messages

- `message.received_customer`: a customer sends a message from the customer portal. Payload: the message.
- `message.received_purchase_order`: a vendor sends a message from the vendor portal. Payload: the message.
- `message.customer_bounced`, `message.bounced_purchase_order`: an email about a sales order, or about a purchase order, cannot be delivered. Payload: the message.
- `message.status_changed`: a customer message's status is set, even to the status it already has. Payload: the message, plus `previous_status_id` and `previous_message_status`.

### Reading payloads

Don't rely on `deposit_amount` or `full_amount` alone to start work: both need an exact match, and both can be sent again after a refund and a new payment. Subscribe to `payment_received.any_amount`, then read the order back (Get a sales order or quote) and compare what was paid with the deposit and the balance.

Every order and quote payload says which it is with `is_quote` (true or false) and `object_type` (`sales_order` or `quote`). The payloads of `order.created` and of the deleted, restored, converted and rush events also have `type` (`order` or `quote`); the `quote.created` and status-change payloads don't. All of them carry older fields such as `is_draft` and `is_created`; use `mode` instead.

Order and quote payloads give `calc_total` and the other `calc_*` amounts in thousandths, an older encoding (`calc_total: 125000` is 125.00; see [Money and quantities](/concepts/money-and-quantities/)). The payloads of `order.created` and of the deleted, restored, converted and rush events also give them as formatted strings with the currency symbol (`formatted_calc_total: "$125.00"`). The `quote.created` and status-change payloads have only some formatted amounts, such as `formatted_invoiced_total` and `formatted_order_balance_due`.

Payment payloads give `amount` as a formatted string for display (such as `"$125.00"`; do not parse it—read the order for exact amounts), and name the payment method in `payment_method_name`. A payment a customer made through the customer portal's checkout also carries `funding_payment_gateway` (the gateway), `funding_transaction_id`, `currency_iso`, `is_external_cash_movement` and `customer_payment_*` fields, and YoPrint sets its `payment_method_name` (such as `Credit Card/Debit Card` or `PayPal`). Status ids in payloads are the same ids the status lists return.

Order and quote payloads include `customer_portal_order_link` and `customer_portal_payment_link`. These links open the customer's portal without a sign-in, so treat payloads as sensitive: do not log or forward them.

To see the exact `payload` of an event for your team, call Show a sample event.

## Permission

Needs the Manage Settings permission.

## Business rules

- `url`, `name`, `enabled_events` (at least one name, no repeats) and `source` are required.
- `url` must be an `https://` URL whose host resolves, and resolves only to public internet addresses. YoPrint looks the host up when you save; it sends nothing to it.
- A `secret` in the body is ignored. YoPrint makes the secret, and it never changes. To get a new secret, create a new subscription and delete the old one.
- `config` can be set only here. Updating a subscription does not change it, and no response shows it.
- A team can have any number of subscriptions, and two subscriptions may use the same URL. Each gets its own copy of every event.

## Side effects

- From now on, matching events are sent to `url` as described in "What YoPrint sends".

## Path parameters

- `team_slug` (string, required): Your team slug, shown as Team ID next to your API key in Profile. Example `your-team`.

## Request body

Content type `application/json`, required.

Fields:

- `enabled_events` (array of strings, required): Event names to subscribe to from the Events table, or `all` for every event (including future events). YoPrint does not validate event names: misspelled names are saved without error, but will never match any event. At least 1 item. Items must be unique.
- `name` (string, required): A name to recognize it by.
- `source` (string, required): Send `yoprint`. YoPrint's Zapier app sends `zapier`, which changes failure handling; don't use it.
- `url` (string, required): Where YoPrint sends events. Must be an `https://` URL on the public internet. YoPrint refuses plain `http://` URLs and any host that resolves to a non-public address (private, loopback, link-local, carrier-grade NAT, multicast or reserved). YoPrint looks up the host when you save, but does not send a test request. Use the final destination URL because redirects are not followed.
- `config` (object, optional): Optional status filters for the status-change events. Without a filter, every status change is sent. The other events ignore `config`, and so does the older `order.status_changed`. Status ids come from the status lists (see Statuses).
  - `job_status_changed_options` (object, optional): Send `job.status_changed` only when the new status is one of these job statuses.
    - `status_ids` (array of strings, optional): Status ids, exactly as the status lists return them. At least 1 item.
  - `message_status_changed_options` (object, optional): Send `message.status_changed` only when the new status is one of these customer message statuses.
    - `status_ids` (array of strings, optional): Status ids, exactly as the status lists return them. At least 1 item.
  - `purchase_order_status_changed_options` (object, optional): Send `purchase_order.status_changed` only when the new status is one of these purchase order statuses.
    - `status_ids` (array of strings, optional): Status ids, exactly as the status lists return them. At least 1 item.
  - `quote_status_changed_options` (object, optional): Send `quote.status_changed` only when the new status is one of these quote statuses.
    - `status_ids` (array of strings, optional): Status ids, exactly as the status lists return them. At least 1 item.
  - `sales_order_status_changed_options` (object, optional): Send `sales_order.status_changed` only when the new status is one of these sales order statuses.
    - `status_ids` (array of strings, optional): Status ids, exactly as the status lists return them. At least 1 item.
  - `shipment_status_changed_options` (object, optional): Send `shipment.status_changed` only when the new status is one of these shipment statuses.
    - `status_ids` (array of strings, optional): Status ids, exactly as the status lists return them. At least 1 item.
  - `work_pipeline_status_changed_options` (object, optional): Send `work_pipeline.status_changed` only when the new status is one of these task statuses.
    - `status_ids` (array of strings, optional): Status ids, exactly as the status lists return them. At least 1 item.

### Example request

```bash
curl -X POST '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 '{
    "config": {
      "job_status_changed_options": {
        "status_ids": [
          "PLksRyu0AR7Bv4"
        ]
      }
    },
    "enabled_events": [
      "job.status_changed"
    ],
    "name": "Ready to ship",
    "source": "yoprint",
    "url": "https://example.com/yoprint/jobs"
  }'
```

Another example: New sales orders and payments:

```bash
curl -X POST '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 '{
    "enabled_events": [
      "order.created",
      "payment_received.any_amount"
    ],
    "name": "Order sync",
    "source": "yoprint",
    "url": "https://example.com/yoprint/webhooks"
  }'
```

## Response

`200` Successful response.

Fields:

- `data` (object, required)
  - `created_at` (string (date-time), required)
  - `disable_reason` (string | null, required): `auto_disable_too_many_errors` when YoPrint turned the subscription off because its deliveries kept failing. Otherwise null. Setting `status` back to `active` does not clear it.
  - `enabled_events` (array of strings, required): Subscribed event names, or `all` for every event.
  - `id` (string, required): The subscription id.
  - `name` (string, required)
  - `secret` (string | null, required): The secret used to sign event payloads: `whsec_` followed by 32 letters and digits. Use it to verify incoming messages. YoPrint creates it when the subscription is created and never changes it. Keep it private. Only the team owner and members whose permission group has Manage Settings can see it; for every other member it is null. It is also null for AI apps reading through YoPrint's MCP server.
  - `source` (string, required): The `source` sent when the subscription was created: the web app sends `yoprint`, YoPrint's Zapier app sends `zapier`.
  - `status` (string, required): Only `active` subscriptions receive events. One of: `"active"`, `"disabled"`.
  - `updated_at` (string (date-time), required)
  - `url` (string | null, required): The destination URL where YoPrint delivers events. On this API, this is the full URL. AI apps reading subscriptions through YoPrint's MCP server see only the origin (scheme and host, such as `https://example.com`) to protect webhook secrets.
- `status` (integer, required)
- `success` (boolean, required)

Example response:

```json
{
  "data": {
    "created_at": "2026-11-03T15:00:00+00:00",
    "disable_reason": null,
    "enabled_events": [
      "order.created",
      "payment_received.any_amount"
    ],
    "id": "V4keXwwxMQyXw7",
    "name": "Order sync",
    "secret": "whsec_YOUR_WEBHOOK_SECRET",
    "source": "yoprint",
    "status": "active",
    "updated_at": "2026-11-03T15:00:00+00:00",
    "url": "https://example.com/yoprint/webhooks"
  },
  "status": 200,
  "success": true
}
```

## Errors

- `401` The API key is missing or wrong (`unauthenticated`).
  - When: The API key is missing or wrong. Fix: Send your personal API key as `Authorization: Bearer YOUR_API_KEY`.
- `403` You are not allowed to do this. `api_access_not_in_plan`: your team's plan doesn't include API access (Pro and above, with an active subscription or trial), so every request answers this until the team upgrades. When your role lacks the permission the operation needs, the body is only `{"message": "Insufficient Permission"}`. Other refusals use the error envelope, with `unauthorized` or `user_is_not_authorized`.
  - When: Your permission group lacks Manage Settings. This 403 has a short body, `{"message": "Insufficient Permission"}`, without `success` or `error`. Fix: Ask the team owner to give your permission group Manage Settings.
- `404` Not found. `page_not_found`: the team slug is unknown, you are not a member of the team, or an id in the path is not one of this team's. `code: null`, with a message that starts "No query results for": there is no record with that id.
- `422` The request was refused and changed nothing. `validation_failed` lists what to fix in `error.fields`; other codes name a business rule, explained in `error.message`. A few checks answer with only `{"message": "..."}`. An operation that calls another service (a payment provider, a carrier, an accounting app) can answer `connect_timeout` ("Please retry again") when that service does not answer in time: the change may or may not have happened there, so read the record back before you retry.
  - When: A required field is missing, `url` is not a URL, `enabled_events` is empty or repeats a name, or a `status_ids` list is empty. `error.code` is `validation_failed`, and `error.fields` names each field with its messages. Fix: Fix the fields listed in `error.fields` and send the request again.
  - When: `url` is refused by the public-address check (`validation_failed`, with `error.fields.url` and `error.field_reasons.url.reason`). `not_public_address`: the URL is not `https://`, has no usable host, or its host reaches an address that is not on the public internet ("Use an https:// URL that reaches a public internet address."). `host_lookup_failed`: the host name could not be looked up ("The host name in this URL could not be looked up. Check the address, or try again in a minute."). Fix: Use an `https://` URL on a public host. For `host_lookup_failed`, check the host name, or try again in a minute if its DNS is new.

## Notes

- Store the `secret` from the response with your other secrets. You can read it again later with Show a webhook subscription, as the team owner or with Manage Settings.
- Check what you will receive before going live: Show a sample event returns a real `payload` built from your team's data.
- Watch deliveries with List deliveries for a webhook subscription.

## Related

- [Show a sample event](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-sample.md): `GET /v1/api/store/{team_slug}/setting/webhook_subscription/sample/{event_name}`
- [List deliveries for a webhook subscription](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-log.md): `GET /v1/api/store/{team_slug}/setting/webhook_subscription/{id}/log`
- [Touch a webhook subscription](https://developer.yoprint.com/reference/webhook-subscriptions/v1-webhook-subscription-update-patch.md): `PATCH /v1/api/store/{team_slug}/setting/webhook_subscription/{hash_id}`
- [List job statuses](https://developer.yoprint.com/reference/statuses/v1-status-list-job.md): `GET /v1/api/store/{team_slug}/setting/status/job`
- [List sales order statuses](https://developer.yoprint.com/reference/statuses/v1-status-list-sales-order.md): `GET /v1/api/store/{team_slug}/setting/status/sales_order`
- [Get a sales order or quote](https://developer.yoprint.com/reference/sales-orders/v2-order-show.md): `GET /v2/api/store/{team_slug}/v2/sales_order/{order_unique_key}`
