Guides

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.

Example webhook payload
{
  "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.

Example payload
{
  "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:

Example order VAT — selected object fields
{
  "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. rate is the VAT percentage, amount is the VAT charged in minor units — the $1.90 here — and country is the customer's VAT country, null when unknown. The whole object is null when the order charged no VAT.

Name
vatobject | nullDeprecated
Description

The legacy VAT object: its amount is the VAT percentage, not money. It stays unchanged so existing integrations keep working — read vat_details instead.

Charge webhook payloads

Charge webhook payloads use the same top-level shape as other webhooks: event, data, and store.

Example charge webhook payload
{
  "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-id is the stable integration-event ID.
  • webhook-timestamp is the Unix timestamp used for this attempt.
  • webhook-signature contains v1,<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.

On this page