> ## Documentation Index
> Fetch the complete documentation index at: https://docs.skinloop.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks overview

> Receive signed, at-least-once Skinloop events.

Webhook event IDs are stable. Delivery is at least once, so acknowledge
quickly, enqueue work, and deduplicate by event ID. Event types include
`payment.initiated`, `payment.pending`, `payment.active`, `payment.hold`,
`payment.completed`, `payment.canceled`, `payment.declined`, `payment.failed`,
and `payment.reverted`.

The optional `data.merchantOrderId` connects a hosted checkout event to your
order. The webhook body is JSON, but signature verification must use its exact
raw bytes.

## Completed delivery

This is a complete `payment.completed` request. The hexadecimal signature shown
is illustrative; Skinloop calculates a different signature with the signing
secret for your endpoint and the exact raw body bytes.

```http theme={null}
POST /webhooks/skinloop HTTP/1.1
Content-Type: application/json
Skinloop-Event-Id: evt_01K5F8N7Y4A2BCDEFGHJKMNPQR
Skinloop-Timestamp: 1789733100
Skinloop-Signature: v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

{
  "version": "1",
  "id": "evt_01K5F8N7Y4A2BCDEFGHJKMNPQR",
  "type": "payment.completed",
  "createdAt": "2026-09-18T12:05:00.000Z",
  "data": {
    "externalPaymentId": "payment_01K5F8J3M8V6STUVWXYZ012345",
    "merchantOrderId": "order_123",
    "game": "cs2",
    "currency": "USD",
    "amount": "49.99",
    "status": "completed",
    "reservationRequired": true,
    "fulfillmentAllowed": true
  }
}
```

Only `payment.completed` with `data.status: "completed"` and
`data.fulfillmentAllowed: true` permits fulfillment. Record completion and
enqueue the unique durable job before returning 2xx; do not deliver the
merchant order inside this HTTP request.

## Non-completed delivery

A CS2 payment in provider hold is not ready for fulfillment:

```http theme={null}
POST /webhooks/skinloop HTTP/1.1
Content-Type: application/json
Skinloop-Event-Id: evt_01K5F8KQ2M9ABCDEFGHJKMNPQRS
Skinloop-Timestamp: 1789732800
Skinloop-Signature: v1=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210

{
  "version": "1",
  "id": "evt_01K5F8KQ2M9ABCDEFGHJKMNPQRS",
  "type": "payment.hold",
  "createdAt": "2026-09-18T12:00:00.000Z",
  "data": {
    "externalPaymentId": "payment_01K5F8J3M8V6STUVWXYZ012345",
    "merchantOrderId": "order_123",
    "game": "cs2",
    "currency": "USD",
    "amount": "49.99",
    "status": "hold",
    "reservationRequired": true,
    "fulfillmentAllowed": false,
    "holdUntil": "2026-09-26T12:00:00.000Z"
  }
}
```

Keep the order reserved during hold. Do not fulfill, credit, delete, or release
it. `holdUntil` is included only for a CS2 `payment.hold` event when the hold
start is known; it is not a completion time or permission to fulfill.

## Field meanings

| Field                           | Use                                                                                                                                          |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                            | Stable webhook event ID. Store it with a unique constraint to deduplicate retries.                                                           |
| `type`                          | Event name in the form `payment.<status>`. Route the event, but still validate `data.status`.                                                |
| `createdAt`                     | Time Skinloop created this event. It is not the delivery-attempt time and does not guarantee arrival order.                                  |
| `data.merchantOrderId`          | Your order ID supplied when the hosted checkout was created. Use it to find the expected local order. It can be absent for legacy checkouts. |
| `data.status`                   | Authoritative payment state represented by this event. Fulfillment is allowed only for `completed`.                                          |
| `data.externalPaymentId`        | Skinloop's public payment identifier for support and reconciliation.                                                                         |
| `data.amount` / `data.currency` | Payment amount and currency. Verify both against the stored order before changing state.                                                     |
| `data.fulfillmentAllowed`       | Explicit safety signal. It is true only for completed payments.                                                                              |

## Map and deduplicate

After verifying the signature:

1. Confirm `Skinloop-Event-Id` exactly equals the body `id`.
2. Insert the authenticated event using `id` as a unique key.
3. Look up your local order using `data.merchantOrderId`.
4. Verify the stored checkout, amount, currency, and expected order agree.
5. Apply the status transition without assuming events arrive in order.
6. For completion, call the same durable enqueue operation used by status
   polling.
7. Return 2xx after the event and any fulfillment job are committed.

A retried event with the same `id` and same authenticated body is already
accepted: return 2xx without applying it again. The same `id` with different
authenticated bytes is a conflict and must not be processed automatically.
Webhook event deduplication does not replace fulfillment-job idempotency; use
both.

Your endpoint must be public HTTPS and must not rely on redirects. Return any
2xx response after authenticating and durably recording the event and any
unique fulfillment job. Do not call an external fulfillment service inside the
webhook request. Enqueue only for `payment.completed`; pending and hold events
are informative.

**Next:** implement [signature verification](/webhooks/signatures) before
testing [retries and duplicates](/webhooks/retries), then implement
[safe fulfillment](/fulfillment-safety).
