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

# External payment adapters

> Connect a payment provider that Shoppex does not support natively with your own signed HTTPS service.

An external payment adapter lets you use a regional or specialist provider
without a native Shoppex integration. You operate a small HTTPS service. It
creates the provider checkout, receives the provider webhook, and reports the
result back to one exact Shoppex payment attempt.

This page is the complete contract. You can build an adapter from this page
alone, in any runtime that speaks HTTPS and HMAC-SHA256.

<Warning>
  An adapter is merchant-operated. Its signature proves that the event came from
  your adapter. It does not prove that Shoppex observed the settlement.

  Adapter payments are marked **merchant-attested**. They are excluded from
  trusted GMV and they do not support automatic refunds. They do collect Shoppex
  platform fees, the same as a natively supported provider. Manual payment
  methods and "mark as paid" stay fee-free.
</Warning>

## When to use an adapter

Use an external adapter when your provider has all three of these:

* an API that creates a hosted checkout session
* a signed server-to-server payment webhook
* a stable provider payment reference or session reference

Use a [manual payment method](/developers/manual-payments) instead when you only
need instructions, a static redirect, or human confirmation.

## How the flow works

1. The buyer selects your external payment method at checkout.
2. Shoppex creates a payment attempt and sends a signed `payment.session.create`
   request to your adapter.
3. Your adapter calls the provider and creates a checkout.
4. Your adapter answers with `checkout_url` and `provider_reference`.
5. Shoppex redirects the buyer to that checkout URL.
6. The provider sends its own signed webhook to your adapter after settlement.
7. Your adapter verifies that webhook and sends a signed `payment.succeeded`
   event to Shoppex.
8. Shoppex matches the attempt, the amount, and the currency. Then it completes
   the order and the fulfillment.

Shoppex creates the attempt before the first request. The `attempt_id` is
therefore the idempotency key of the whole flow. A late adapter response cannot
replace a newer buyer attempt.

## What you must build

Your adapter is one HTTPS service with three endpoints. The paths of the first
two are yours to choose. The conformance path is fixed.

| Endpoint                                    | Called by     | Purpose                      | Deadline              |
| ------------------------------------------- | ------------- | ---------------------------- | --------------------- |
| `POST` session endpoint                     | Shoppex       | Create the provider checkout | 15 s                  |
| `POST` provider webhook                     | Your provider | Receive the settlement       | Your provider decides |
| `POST /.well-known/shoppex-payment-adapter` | Shoppex       | Answer the conformance probe | 5 s                   |

You configure one URL only. Shoppex derives the conformance URL from the origin
of the session URL, not from its path. A session endpoint at
`https://pay.example.com/adapters/regional/sessions` therefore gets probed at
`https://pay.example.com/.well-known/shoppex-payment-adapter`.

<Warning>
  Give each adapter its own host or subdomain. Two adapters on one host share that
  one conformance path but hold two different shared secrets, so the probe of the
  second adapter fails with an invalid signature.
</Warning>

The host must serve public HTTPS. Redirects to private networks are refused.

<Note>
  Cloudflare Workers, Node, Bun, Go, and any other HTTPS runtime work. The
  contract is HTTP, JSON, and HMAC-SHA256. It has no framework requirement and no
  Shoppex SDK requirement.
</Note>

You also need durable storage for one row per payment attempt. Postgres, SQLite,
Cloudflare D1, or an equivalent store is sufficient. The example schema is in
[Claim the attempt](#claim-the-attempt-before-you-call-the-provider).

## Sign and verify every request

Both directions use the [Standard Webhooks](https://www.standardwebhooks.com/)
header format:

```text theme={"system"}
webhook-id: <unique message ID>
webhook-timestamp: <Unix seconds>
webhook-signature: v1,<base64 HMAC-SHA256>
```

The signed string joins three parts with periods:

```text theme={"system"}
<webhook-id>.<webhook-timestamp>.<exact raw body>
```

Six rules apply to every signature:

1. Sign and verify over the **raw request body**. Do not parse the JSON first
   and serialize it again. A re-serialized body has a different signature.
2. Shoppex shows the shared secret with a `whsec_` prefix. Remove that prefix
   and Base64-decode the remainder. Those bytes are the HMAC key.
3. Refuse a timestamp that is more than 5 minutes away from the current time.
   Shoppex applies the same window to your events.
4. Compare the signatures in constant time. The `webhook-signature` header can
   hold more than one space-separated version. Accept the request when one
   `v1,` entry matches.
5. Decode the header defensively. An unauthenticated caller controls its
   content, so a malformed Base64 candidate must fail the check and not raise an
   error. Both endpoints are public. An uncaught decode error answers with HTTP
   500 and fills your logs.
6. Limit the request size before you read the body. Verification needs the
   buffered raw body, so an unauthenticated caller can otherwise exhaust your
   memory. Both signed payloads are small JSON objects. Refuse more than 64 KiB
   with HTTP 413.

This implementation uses WebCrypto only. It runs unchanged in Workers, Node 18
and later, Bun, and Deno:

```ts signatures.ts theme={"system"}
const encoder = new TextEncoder();
const TOLERANCE_SECONDS = 300;

function secretKeyBytes(secret: string): Uint8Array {
  const trimmed = secret.trim();
  if (!trimmed.startsWith('whsec_')) return encoder.encode(trimmed);
  const decoded = base64ToBytes(trimmed.slice('whsec_'.length));
  if (!decoded) throw new Error('The shared secret is not valid Base64.');
  return decoded;
}

// A caller controls the signature header. Invalid Base64 must return null here,
// not throw: an uncaught error answers a public endpoint with HTTP 500.
function base64ToBytes(value: string): Uint8Array | null {
  try {
    return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
  } catch {
    return null;
  }
}

function bytesToBase64(bytes: Uint8Array): string {
  let binary = '';
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
}

function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
  if (left.length !== right.length) return false;
  let difference = 0;
  for (let index = 0; index < left.length; index += 1) {
    difference |= left[index]! ^ right[index]!;
  }
  return difference === 0;
}

async function hmac(payload: string, secret: string): Promise<Uint8Array> {
  const key = await crypto.subtle.importKey(
    'raw',
    secretKeyBytes(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  return new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)));
}

export async function sign(input: {
  id: string;
  timestamp: string;
  rawBody: string;
  secret: string;
}): Promise<string> {
  const digest = await hmac(`${input.id}.${input.timestamp}.${input.rawBody}`, input.secret);
  return `v1,${bytesToBase64(digest)}`;
}

export async function verify(input: {
  id: string | null;
  timestamp: string | null;
  signature: string | null;
  rawBody: string;
  secret: string;
}): Promise<boolean> {
  if (!input.id || !input.timestamp || !input.signature) return false;

  const timestamp = Number(input.timestamp);
  const now = Date.now() / 1000;
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = await hmac(`${input.id}.${input.timestamp}.${input.rawBody}`, input.secret);
  const candidates = input.signature
    .trim()
    .split(/\s+/)
    .filter((entry) => entry.startsWith('v1,'))
    .map((entry) => base64ToBytes(entry.slice(3)))
    .filter((entry): entry is Uint8Array => entry !== null);

  return candidates.some((candidate) => equalBytes(candidate, expected));
}
```

The provider webhook uses its own signature algorithm. Use the official verifier
of your provider for that direction. Never weaken or remove it in production.

## Create the provider session

Shoppex sends this request to your session endpoint and waits 15 seconds:

```json theme={"system"}
{
  "version": "2026-08-14",
  "type": "payment.session.create",
  "data": {
    "attempt_id": "11111111-1111-4111-8111-111111111111",
    "invoice_id": "22222222-2222-4222-8222-222222222222",
    "amount_minor": 4999,
    "currency": "EUR",
    "customer_email": "buyer@example.com",
    "description": "Order 22222222",
    "success_url": "https://checkout.shoppex.io/invoice/22222222/success",
    "cancel_url": "https://checkout.shoppex.io/invoice/22222222",
    "event_url": "https://api.shoppex.io/v1/external-payment-adapters/ADAPTER_ID/events"
  }
}
```

Every key is always present. Two of them accept `null`:

| Field            | Type                 | Notes                                       |
| ---------------- | -------------------- | ------------------------------------------- |
| `attempt_id`     | UUID                 | The idempotency key of the attempt          |
| `invoice_id`     | UUID                 | The Shoppex invoice                         |
| `amount_minor`   | integer              | Minor units. Always positive.               |
| `currency`       | string               | Three uppercase letters                     |
| `customer_email` | string or **`null`** | The invoice can have no email               |
| `description`    | string               | 1 to 500 characters                         |
| `success_url`    | HTTPS URL            | The buyer returns here after payment        |
| `cancel_url`     | HTTPS URL            | The buyer returns here after a cancellation |
| `event_url`      | HTTPS URL            | Your target for the payment event           |

Accept `customer_email: null`. A schema that requires a string rejects every
valid session for an invoice without an email, and the buyer cannot pay.

`amount_minor` is an integer in minor units. The value `4999` with the currency
`EUR` means €49.99. Never send this amount through a floating point number.
Shoppex generates the real `event_url`. Store it and call it unchanged.

Answer with HTTP 200 and this body:

```json theme={"system"}
{
  "version": "2026-08-14",
  "provider_reference": "provider_session_123",
  "checkout_url": "https://pay.provider.example/session/123",
  "expires_at": "2026-08-14T13:00:00Z"
}
```

Shoppex validates this answer strictly and reports one generic error for every
rejection. Obey these limits:

| Field                | Rule                                                                                   |
| -------------------- | -------------------------------------------------------------------------------------- |
| `version`            | Exactly `2026-08-14`                                                                   |
| `provider_reference` | 1 to **255** characters. The stable reference that the provider webhook carries later. |
| `checkout_url`       | HTTPS                                                                                  |
| `expires_at`         | Optional. Accepts `null`. An ISO 8601 timestamp that **carries `Z` or an offset**.     |

A timestamp such as `2026-08-14T13:00:00` has no zone and fails. A provider
reference longer than 255 characters fails. Store your own longer identifier
separately and send a reference within the limit.

Five rules apply to the provider call:

1. Send `attempt_id` as the idempotency key or the metadata of the provider.
2. Store the provider reference together with the Shoppex `event_url`, the exact
   amount, and the currency.
3. Set a timeout below 15 seconds. A slower provider must fail, not hold the
   buyer.
4. Do not follow redirects to the provider. A redirect can send your API key to
   another host.
5. Read the answer with a size limit. An upstream error page must not fill your
   memory.

## Claim the attempt before you call the provider

Two simultaneous requests for the same attempt must never open two provider
checkouts. A read-then-call check does not prevent this. Both requests read an
empty table and both call the provider.

Claim the attempt with one atomic insert **before** the provider call. Only the
request that wins the insert calls the provider. The examples use SQLite and
Cloudflare D1 syntax. Adapt the types and the date functions for another
database:

```sql theme={"system"}
CREATE TABLE payment_sessions (
  attempt_id TEXT PRIMARY KEY,
  request_fingerprint TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'claimed',
  provider_reference TEXT,
  checkout_url TEXT,
  expires_at TEXT,
  event_url TEXT NOT NULL,
  amount_minor INTEGER NOT NULL,
  currency TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX payment_sessions_provider_reference_idx
  ON payment_sessions (provider_reference)
  WHERE provider_reference IS NOT NULL;
```

The `request_fingerprint` is a hash over all fields of the signed request, with
sorted keys. It separates a true replay from a changed payload. A hash over the
money fields alone treats a changed invoice, buyer email, or return URL as an
identical replay.

When the insert finds an existing row, answer as follows:

| Existing row                              | Answer                                                                                                                    |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Same fingerprint, status `completed`      | HTTP 200 with the stored session                                                                                          |
| Same fingerprint, status `claimed`        | HTTP 409, creation is in progress. An old row here is an orphan. See [Recover a stuck attempt](#recover-a-stuck-attempt). |
| Same fingerprint, status `failed_unknown` | HTTP 409, the attempt needs recovery                                                                                      |
| Different fingerprint                     | HTTP 409, the attempt was claimed with another payload                                                                    |

### Handle an unknown provider outcome

A failed provider call does not prove that the provider created nothing. The
request can arrive and the answer can be lost. A retry then opens a second
checkout that the buyer can also pay.

Release the claim only for a validation rejection. Quarantine everything else:

| Provider outcome                 | Action                                   |
| -------------------------------- | ---------------------------------------- |
| HTTP 400, 401, 403, 404, 422     | Delete the claim. No session exists yet. |
| Timeout or network error         | Set status `failed_unknown`.             |
| HTTP 5xx                         | Set status `failed_unknown`.             |
| Redirect, 408, 409, 425, 429     | Set status `failed_unknown`.             |
| HTTP 2xx with an unreadable body | Set status `failed_unknown`.             |

Answer Shoppex with HTTP 502 in all of these cases. The buyer is never stuck.
Shoppex can start a new payment attempt, which carries a new `attempt_id` and
its own claim.

### Recover a stuck attempt

Two states need this procedure. A `failed_unknown` row is a quarantined claim. A
`claimed` row that is older than one session timeout is an orphan: the process
died, or its completion write failed, after the provider call. Both answer HTTP
409 forever, and a provider webhook cannot resolve a reference that was never
stored.

Find the orphans:

```sql theme={"system"}
SELECT attempt_id, status, created_at FROM payment_sessions
WHERE status = 'failed_unknown'
   OR (status = 'claimed' AND created_at < datetime('now', '-15 minutes'));
```

<Warning>
  Open the dashboard of your provider and search for a session with this
  `attempt_id` before you change anything. A deleted row without this step is what
  lets a second provider session appear.
</Warning>

If a provider session exists, complete the row instead of deleting it. The
stored reference must match the events that the provider will send:

```sql theme={"system"}
UPDATE payment_sessions
SET status = 'completed', provider_reference = ?, checkout_url = ?
WHERE attempt_id = ? AND status IN ('failed_unknown', 'claimed');
```

If no provider session exists, delete the row. The next Shoppex attempt then
claims cleanly:

```sql theme={"system"}
DELETE FROM payment_sessions
WHERE attempt_id = ? AND status IN ('failed_unknown', 'claimed');
```

## Report the payment to Shoppex

Your provider webhook handler verifies the provider signature, loads the stored
session by `provider_reference`, and compares the settled values. Then it sends
a signed event to the stored `event_url`.

```json theme={"system"}
{
  "version": "2026-08-14",
  "type": "payment.succeeded",
  "data": {
    "attempt_id": "11111111-1111-4111-8111-111111111111",
    "provider_reference": "provider_session_123",
    "amount_minor": 4999,
    "currency": "EUR",
    "occurred_at": "2026-08-14T12:32:04Z"
  }
}
```

The `type` field accepts `payment.processing`, `payment.succeeded`, and
`payment.failed`. `occurred_at` is optional and accepts `null`.

Seven rules apply to this direction:

1. Read the settled amount and currency from the verified provider event. Never
   copy your stored amount into the event as if the provider reported it.
2. If you find no session for the `provider_reference`, answer your provider
   with a **retryable** status such as HTTP 503. Never answer HTTP 404. The
   provider can send this webhook before your session handler has stored the
   reference. A permanent rejection makes every provider that does not redeliver
   drop a paid order for good.
3. If the amount or the currency does not match your stored session, send no
   event. Answer your provider with HTTP 409.
4. Map only final provider settlement to `payment.succeeded`. A browser return
   URL is not proof of payment.
5. Use the stable event ID of the provider as the `webhook-id`. Provider
   redelivery and your own retries then deduplicate to one payment decision in
   Shoppex.
6. Do not follow redirects on the event URL. Any 2xx page anywhere then
   satisfies the delivery.
7. Deliver from a queue or a retry loop. A provider webhook must not wait for
   Shoppex.

Shoppex answers with one of these:

| Status | Body               | Meaning                                                                 |
| ------ | ------------------ | ----------------------------------------------------------------------- |
| 200    | `{"message":"OK"}` | The event is reconciled. Acknowledge the delivery.                      |
| 400    | Error message      | The payload is invalid, or the attempt does not belong to this adapter. |
| 401    | Error message      | The signature, the timestamp, or the adapter ID is invalid.             |
| 409    | Error message      | The amount or the currency does not match the attempt.                  |

Treat only HTTP 200 with `{"message":"OK"}` as delivered. Retry every other
answer with a backoff. After the retries are exhausted, move the event to a
dead-letter store for investigation. Never discard it silently.

## Answer the conformance probe

Shoppex tests your adapter through
`POST /.well-known/shoppex-payment-adapter`. The probe is signed with the same
shared secret. Verify it exactly as you verify a session request, and answer
HTTP 401 when the signature is invalid.

The request has two modes:

```json theme={"system"}
{
  "version": "2026-08-14",
  "type": "adapter.conformance.run",
  "data": {
    "challenge_id": "33333333-3333-4333-8333-333333333333",
    "mode": "standard",
    "expected_event": {
      "attempt_id": "11111111-1111-4111-8111-111111111111",
      "provider_reference": "conformance-reference",
      "amount_minor": 4999,
      "currency": "EUR"
    }
  }
}
```

For `mode: "timeout"`, wait longer than 1 second and then answer HTTP 204.
Shoppex aborts this probe after 500 ms and expects that abort.

For `mode: "standard"`, build four signed samples of a `payment.succeeded`
event and answer within 5 seconds. Sign each sample with the **shared secret**
and the same method that you use for a real event. Shoppex verifies every sample
with that secret. A sample signed with your provider secret fails the check.

| `scenario`              | Content                                          |
| ----------------------- | ------------------------------------------------ |
| `valid_event`           | The exact values of `expected_event`             |
| `amount_mismatch`       | The same values with a different `amount_minor`  |
| `duplicate_event_first` | The exact values of `expected_event`             |
| `duplicate_event_retry` | A byte-identical copy of `duplicate_event_first` |

The two duplicate samples must repeat the same `webhook_id`,
`webhook_timestamp`, `webhook_signature`, and `raw_body`. This proves that a
retry keeps one stable event identity.

```json theme={"system"}
{
  "version": "2026-08-14",
  "type": "adapter.conformance.result",
  "data": {
    "challenge_id": "33333333-3333-4333-8333-333333333333",
    "samples": [
      {
        "scenario": "valid_event",
        "webhook_id": "conformance-valid-33333333",
        "webhook_timestamp": "1786000000",
        "webhook_signature": "v1,...",
        "raw_body": "{\"version\":\"2026-08-14\",\"type\":\"payment.succeeded\",\"data\":{...}}"
      }
    ]
  }
}
```

Each `raw_body` is the exact string that you signed. Shoppex verifies each
signature against that string.

## Configure the adapter in Shoppex

<Steps>
  <Step title="Deploy the adapter">
    Deploy your service and add the provider credentials as secrets. Copy the public
    session URL, for example
    `https://payments.example.com/sessions`.
  </Step>

  <Step title="Add the adapter">
    In Shoppex, open **Settings → Payments → External** and select **Add adapter**.
    Enter the session URL. Shoppex shows a `whsec_...` shared secret one time only.
  </Step>

  <Step title="Store the shared secret">
    Save that secret in your service and deploy again. The adapter cannot verify a
    Shoppex request before this step.
  </Step>

  <Step title="Run the test">
    Select **Test** on the adapter. Enable the adapter after all six checks pass and
    after you tested the real provider in its sandbox.
  </Step>
</Steps>

A shop can hold up to 10 adapters.

## Test before going live

The **Test** button sends the signed challenges and checks six things:

1. the versioned request and the challenge response
2. the rejection of a changed Shoppex signature
3. the signatures and the attempt binding on the event samples
4. the detection of a wrong amount
5. one stable event identity across a duplicate delivery
6. the 500 ms timeout deadline of Shoppex

This test is safe on a configured adapter. It creates no provider checkout, no
Shoppex payment attempt, and it touches no invoice. A green result proves that
your service speaks the Shoppex contract. It does not prove that your provider
API mapping or your provider webhook verifier is correct.

Test these failure cases against a sandbox provider:

1. A changed Shoppex body with the original signature returns 401.
2. A provider webhook with a wrong signature returns 401.
3. An unknown provider reference returns a retryable status and creates no
   event.
4. A duplicate successful webhook results in one completed Shoppex order.
5. A Shoppex event endpoint that returns 500 causes a retry, then a
   dead-letter entry.
6. A second buyer attempt before the first session answer cannot make the late
   first answer current.
7. A wrong provider amount or currency sends no event, and the invoice stays
   unpaid.

## Restrict an adapter to specific currencies

Many regional providers settle in one currency only. In the settings of the
adapter, **Accepted Currencies** defaults to *All currencies*. Select individual
codes to restrict it.

Shoppex hides a restricted adapter at checkout for every other currency, and
refuses the session server-side with HTTP 400. Your service therefore never
receives a session request in an excluded currency. Keep the amount and currency
comparison against the provider answer. That check guards a different failure.

## Version-one limits

* one-time payments only
* no partial payments and no overpayments
* no Shoppex-initiated refund, dispute, or subscription operations
* no overlap window for shared-secret rotation. Create a new adapter for a
  planned key replacement.
* public HTTPS endpoints only, with no redirects to private networks

These limits fail closed. Request a versioned contract extension when your
provider needs a new lifecycle. Do not infer a missing value.
