Receive webhooks
AI agents: read /llms.txt; add .md to any page URL. This page as Markdown: /guides/webhooks.md. Index of every page and the rules for every call: /llms.txt. Guides and concepts in one file: /llms-full.txt. OpenAPI: /openapi.json.
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.
- Something happensAn order is created, a payment arrives…
- YoPrint POSTs the eventAbout 10 seconds later, signed with your secret
- You check and answerVerify the signature, then answer
200 - Handle it onceUse 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.
1. Build the endpoint
Section titled “1. Build the endpoint”Your endpoint receives a POST with a JSON body:
{ "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"}ididentifies the event. Retries of the same event carry the sameid.typeis the event name.payloadis 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
Section titled “2. Create the subscription”/v1/api/store/{team_slug}/setting/webhook_subscription
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_eventsandsourceare required. Send"source": "yoprint". The valuezapieris for YoPrint’s Zapier app.urlmust be a publichttps://address. See Which URLs YoPrint accepts.enabled_eventslists event names. The single valueallsubscribes 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
Section titled “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
Section titled “3. See what an event looks like”/v1/api/store/{team_slug}/setting/webhook_subscription/sample/{event_name}
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
Section titled “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.
- Read the raw body bytes, before any JSON parsing.
- Compute HMAC-SHA256 of those bytes, keyed with the whole secret,
whsec_prefix included, and hex-encode it. - Compare it with
X-Signatureusing 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.
Node.js 18 or later, with Express.
import express from 'express';import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.YOPRINT_WEBHOOK_SECRET; // the whole secret, whsec_ includedconst 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 3 with Flask 2 or later.
import hashlibimport hmacimport jsonimport os
from flask import Flask, request
SECRET = os.environ["YOPRINT_WEBHOOK_SECRET"].encode() # the whole secret, whsec_ includedhandled = 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"])Plain PHP 7.4 or later.
<?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.The signature has no timestamp. To guard against a replayed delivery, remember the event ids you’ve processed and ignore repeats.
5. Handle retries and duplicates
Section titled “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
Section titled “6. Check deliveries”/v1/api/store/{team_slug}/setting/webhook_subscription/{id}/log
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:
/v1/api/store/{team_slug}/setting/webhook_subscription/{hash_id}
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.
/v1/api/store/{team_slug}/setting/webhook_subscription/{hash_id}
To stop for good, delete it. YoPrint then sends one DELETE request to the subscription’s URL.
/v1/api/store/{team_slug}/setting/webhook_subscription/{hash_id}
Test a delivery
Section titled “Test a delivery”There’s no button to send a test event. To get a real delivery without touching real orders:
- Subscribe a test URL to
order.createdandorder.deleted. - Create a draft sales order for a test customer, add a line, and publish it. That sends
order.created. - Delete the order. That sends
order.deleted. - 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.
If the team’s plan changes
Section titled “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
403api_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
Section titled “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:
{ "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:
/v1/api/store/{team_slug}/setting/status/job
Events
Section titled “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.createdandquote.createdfire when the order or quote is published, not when the draft is made.- In payment events (
payment_received.*),payload.amountis a formatted string for display (such as"$125.00"), not a number. Don’t parse it. For exact values, read the order:paid_amountis in thousandths, and totals are decimal pairs (order_item_cart_total,order_balance_due,deposit_amount_due). See Money and quantities.
{ "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.amountis also a formatted string (such as"$50.00"). For exact funds, read the customer (wallet_fundis in thousandths). -
Which payment event to listen to:
To know when… Listen to Then Any payment arrives payment_received.any_amountIt fires for every payment. The order is fully paid payment_received.full_amountIt fires when nothing is left to pay. The deposit is paid payment_received.any_amountRead the order’s deposit_amount_due: 0 or less means paid.payment_received.deposit_amountfires only when the total paid is exactly the deposit, so a bigger payment never sends it. -
order.status_changedis an older event for both sales orders and quotes, sent as well assales_order.status_changedorquote.status_changed. A subscription toallgets two events for one status change. Use the newer two.
What never fires
Section titled “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
Section titled “Pitfalls”- Webhook amounts are for display. The
amountin 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. - Public
https://URLs only. A local, private or plainhttp://address is refused. To try webhooks from your own machine, use a tunnel with a publichttps://address. - Event names aren’t checked. A misspelled event is saved and never fires. Copy names from the table above.
- Answer exactly
200. Other2xxcodes 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
httptohttps, 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.createdandquote.createdcome at publish, not before. Deleting a draft still firesorder.deletedorquote.deleted.
