Webhooks
Let SellApp notify your app, verify each event, and handle deliveries safely.
Instead of repeatedly asking whether an order changed, let SellApp tell you. A webhook is an HTTP request sent to your application when an event happens, such as an order completing or a support ticket receiving a message.
Registering webhooks
To register a webhook, you need a URL in your application that SellApp can call. You can configure a new webhook from your storefront developers settings under the developers tab.
Add your callback URL, pick the events you want to listen for, and save the webhook. Whenever one of those events happens, SellApp will send a webhook request to your endpoint.
You can also manage destinations programmatically with the Webhook Channels API. Channels use stable UUIDs, reject private-network destinations, and support safe one-attempt test deliveries.
Consuming webhooks
When your app receives a webhook request from SellApp, inspect the event
attribute to see what triggered it. The first part of the event type tells you
the payload category, such as an order or product.
Webhook channel configuration, search, and test requests use these same public
event names. For example, selecting order.created enables delivery and testing
of order.created payloads. SellApp keeps the corresponding internal
notification subscription private.
{
"id": "01992a65-e064-71ba-b38f-902b7966a6be",
"created_at": "2026-08-30T14:37:29.000000Z",
"event": "order.completed",
"version": "1",
"data": {
"id": 236
},
"store": 1
}Here, event tells you what happened and data.id tells you which order it
happened to. The example shows only selected order fields.
id identifies the delivery. Automatic retries and manual redelivery preserve
it, so save it with the full payload under a database uniqueness constraint
before processing. created_at is the
time the delivery was created, not necessarily the time the underlying resource
was last updated.
Order webhook payloads include customer_information. When available, this object now includes discord_data for Discord-linked purchases and billing_details for billing details captured during checkout.
Order webhook payloads can include product variants whose deliverable.types contains CREDITS. For credits with fractional unit prices, SellApp may normalize invoice_payment.payment_details to quantity: 1 and unit_price equal to the line subtotal so the webhook remains compatible with integer minor-unit payment fields.
Charge webhook payloads include custom_payment_method_id: the selected custom method's ULID, or null. For custom payments, charge.review means the customer submitted confirmation or proof; it does not prove funds were received. Seller approval emits charge.completed. For an initialized custom wallet top-up, successful completion also credits the exact deposit and saved bonus once. Customer confirmation and redirect returns add no wallet balance.
Delivery behavior
Seller webhook events are delivered asynchronously. Your endpoint must return
any 2xx status within 30 seconds. Save the full payload and recoverable
pending work before responding, then process it in the background;
do not wait for email, fulfillment, or third-party API calls.
Normal deliveries make at most ten attempts over roughly 75 hours. A timeout,
network failure, redirect, or non-2xx response fails the attempt. SellApp records
the next attempt durably and a scheduler dispatches it when due. The delay grows
between attempts with jitter; a valid Retry-After value on a throttled or
temporarily unavailable response can set the next delay. Test deliveries make
one synchronous attempt so the dashboard or API can report the result immediately.
Retries can deliver the same event more than once, and event ordering is not
guaranteed. Delivery can also fail after the final attempt; monitor failures and
check for missed work. A later resource state can arrive before an earlier one.
Deduplicate on the top-level id, then compare
the resource state in data with the state your application has already
processed.
New events include a string version. A new version can change the event's
payload contract; branch on event and version, tolerate additive fields,
and retain a safe fallback for an unknown version. Historical deliveries can
omit metadata added after they were first sent.
Event types
- Name
cash_app.payment_requires_attention- Description
A CashApp payment is pending.
- Name
charge.created- Description
A new charge was created.
- Name
charge.completed- Description
A charge was completed.
- Name
charge.disputing- Description
A charge entered the dispute flow.
- Name
charge.disputed- Description
A charge was disputed.
- Name
charge.partial_paid- Description
A charge received a partial payment.
- Name
charge.review- Description
A charge was marked for review.
- Name
charge.refunded- Description
A charge was refunded.
- Name
charge.voided- Description
A charge was voided.
- Name
feedback.created- Description
A customer left feedback.
- Name
order.created- Description
A new order was created.
- Name
order.paid- Description
The order entered the paid state and fulfillment was queued. Delivery work may still be pending.
- Name
order.completed- Description
The order entered its completed state. External access grants, notifications, and your receiver's business work may still be pending.
- Name
order.disputed- Description
A paid order was disputed.
- Name
order.manual_payment_completed- Description
A customer submitted completion details for a manual payment.
- Name
order.partial_paid- Description
An order received a partial payment.
- Name
product.created- Description
A new product was created.
- Name
product.updated- Description
An existing product was updated.
- Name
product.trashed- Description
A product was successfully deleted.
- Name
subscription.created- Description
A product subscription was created.
- Name
subscription.renewed- Description
A product subscription was renewed.
- Name
subscription.cancelled- Description
A product subscription was cancelled.
- Name
ticket_message.created- Description
A new ticket message was created.
- Name
variant.created- Description
A new product variant was created.
- Name
variant.updated- Description
An existing product variant was updated.
- Name
variant.sold_out- Description
An existing product variant ran out of stock.
{
"id": "01992a65-e064-71ba-b38f-902b7966a6be",
"created_at": "2026-08-30T14:37:29.000000Z",
"event": "ticket_message.created",
"version": "1",
"data": {
"author": "CUSTOMER",
"sender": "maya@example.com",
"content": "Could you help me access the founder memo?",
"ticket_id": 1337,
"updated_at": "2026-08-30T13:37:29.000000Z",
"created_at": "2026-08-30T13:37:29.000000Z",
"id": 6969,
"ticket": {
"id": 1337,
"title": "Design kit",
"status": "OPEN",
"customer": {
"id": "c4e45131-a187-3476-9efd-1fb6fbbf2243",
"email": "maya@example.com"
},
"reference": {
"id": 87654,
"type": "App\\Models\\Invoice"
},
"created_at": "2026-08-30T13:37:29.000000Z",
"updated_at": "2026-08-30T13:37:29.000000Z",
"store_id": 1,
"read_by": 1,
"archived": 0
}
},
"store": 1
}VAT on order webhook payloads
Order webhook payloads describe VAT twice, in customer_information and in each customer's pivot. Here is a 19% VAT charged on a $10.00 order:
{
"vat": { "id": "DE123456789", "amount": 19, "country": "DE" },
"vat_details": { "rate": "19", "amount": "190", "id": "DE123456789", "country": "DE" }
}- Name
vat_detailsobject | null- Description
The charged VAT.
rateis the VAT percentage,amountis the VAT charged in minor units — the $1.90 here — andcountryis the customer's VAT country,nullwhen unknown. The whole object isnullwhen the order charged no VAT.- Name
vatobject | nullDeprecated- Description
The legacy VAT object: its
amountis the VAT percentage, not money. It stays unchanged so existing integrations keep working — readvat_detailsinstead.
Charge webhook payloads
Charge webhook payloads use the same top-level shape as other webhooks:
event, data, and store.
{
"id": "01992a65-e064-71ba-b38f-902b7966a6be",
"created_at": "2026-08-30T14:37:29.000000Z",
"event": "charge.completed",
"version": "1",
"data": {
"status": "COMPLETED",
"id": 1234,
"email": "maya@example.com",
"payment_method": "STRIPE",
"custom_payment_method_id": null,
"currency": "USD",
"total": 49.99,
"coupon": null,
"reference": "External order #1001",
"description": "One-time setup charge",
"return_url": "https://example.com/thank-you",
"gateway_data": {
"transaction_id": "pi_123"
}
},
"store": 1
}Keep your webhook response body small: SellApp stops reading responses over
65,536 bytes (64 KiB), which can cause delivery to fail and be retried. Save the
full verified payload and recoverable work durably before returning 2xx; an
empty acknowledgement is sufficient. Make processing safe for duplicate events.
Security
To verify that a webhook came from SellApp, validate its signature before parsing or processing the event. To create a webhook signing secret, navigate to your storefront developers settings, click New Secret, and save it.
New outbound deliveries also follow the Standard Webhooks format:
webhook-idis the stable integration-event ID.webhook-timestampis the Unix timestamp used for this attempt.webhook-signaturecontainsv1,<base64(HMAC-SHA256(...))>.
Build the signing input as id.timestamp.rawBody, using the header values and
the exact raw body bytes. A retry or replay keeps the event ID but gets a new
timestamp and signature. Check the timestamp against your replay window, then
compare the decoded signature in constant time.
The legacy payload and Signature header remain supported unchanged. For that
scheme, calculate HMAC SHA-256 over
the exact raw request body bytes using the webhook secret. Do not parse and
re-serialize JSON before verification because key order or whitespace changes
the signature value. Compare signatures in constant time: use your language's
secure comparison function, as below, rather than ordinary string equality.
This avoids revealing matching portions through comparison timing.
const signature = req.headers['signature'] ?? '';
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const signatureBytes = Buffer.from(signature, 'hex');
const expectedBytes = Buffer.from(expected, 'hex');
const valid = signatureBytes.length === expectedBytes.length
&& crypto.timingSafeEqual(signatureBytes, expectedBytes);
if (valid) {
// Request has been verified
} else {
// Request could not be verified
}A matching signature shows that the body was signed with your webhook secret. Keep that secret safe: anyone who has it can sign a request. A valid signature alone does not tell you whether you already processed the event.
Replay protection and processing
After signature verification, reject malformed or unexpectedly old payloads
according to your risk policy, then durably store the top-level id, full
verified payload, and pending processing state. Retain processed IDs for at
least as long as your business records need
to prevent duplicate side effects; SellApp does not impose a receiver-side
retention window.
Use a database uniqueness constraint rather than an in-memory cache alone. Return a 2xx response for a duplicate only when the original payload and recoverable work have committed. For a new ID, commit the payload and pending inbox row before acknowledging. The inbox is your database record of work still to do. A worker can read it directly; if you use a separate queue service, save a record of the message to send in the same transaction and send from it. Saving only the ID and enqueueing after acknowledgement can lose work on a crash. See Fulfil an order for the full recovery flow.
The event timestamp is signed as part of the body, but the signature itself does not contain a separate freshness window. Your receiver is responsible for the acceptable age policy.
Test and redeliver
Use the Webhook Channels API test operation or the dashboard test control to send a representative signed event to a current channel. The recent-deliveries view can queue a manual redelivery to that channel. Redelivery preserves the payload ID for deliveries created with the current contract, so the same duplicate-delivery handling is tested.
Discord webhooks and email notifications
Traditional webhook delivery cannot be used to post directly into a Discord channel. Instead, go to your store notifications settings and configure your Discord webhook URL there.
Discord and email notification settings use a broader notification catalog than
traditional webhook channels. Do not assume every notification key has a public
webhook event; use the Webhook Channels API's allowed_notifications enum as
the authoritative list. For setup instructions, see the creating
notifications guide.