> ## 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 a signed Cloudflare Worker adapter.

External payment adapters let you use a regional or specialist provider without
waiting for a native Shoppex integration. Your adapter creates the provider
checkout, receives the provider webhook, and reports the result back to the
exact Shoppex payment attempt.

<Warning>
  An adapter is merchant-operated. Its signature proves that the event came from
  your configured adapter, not that Shoppex independently observed settlement.
  Adapter payments are marked **merchant-attested**, are excluded from trusted
  GMV, and do not support automatic refunds. They do collect Shoppex platform
  fees, like payments through a natively supported provider — the completing
  event is signed, bound to one attempt, and checked against its amount.
  Manual payment methods and "mark as paid" stay fee-free.
</Warning>

## When to use an adapter

Use an external adapter when the provider has:

* an API that creates a hosted checkout session;
* a signed server-to-server payment webhook; and
* a stable provider payment 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

```mermaid theme={"system"}
sequenceDiagram
  participant Buyer
  participant Shoppex
  participant Worker as Your adapter Worker
  participant Provider
  Buyer->>Shoppex: Select external payment method
  Shoppex->>Shoppex: Create payment attempt
  Shoppex->>Worker: Signed payment.session.create
  Worker->>Provider: Create checkout with attempt metadata
  Provider-->>Worker: checkout_url + provider_reference
  Worker-->>Shoppex: checkout_url + provider_reference
  Shoppex-->>Buyer: Redirect to provider
  Provider->>Worker: Signed provider webhook
  Worker->>Worker: Verify and enqueue event
  Worker->>Shoppex: Signed payment.succeeded
  Shoppex->>Shoppex: Match attempt, amount, and currency
  Shoppex-->>Buyer: Complete order and fulfillment
```

Shoppex creates the attempt **before** the first request. This makes
`attempt_id` the idempotency key and prevents a delayed Worker response from
replacing a newer buyer attempt.

## Start from the Hono Worker

The Shoppex repository includes a production-shaped starter at
`workers/external-payment-adapter-starter`. From the Shoppex repository root,
install the workspace dependencies:

```bash theme={"system"}
bun install
```

If you extract the starter into another repository, replace the
`workspace:*` dependency on `@shoppex/contracts` with the released package
version that contains this contract.

The starter has four small pieces:

* `POST /sessions` verifies Shoppex and creates the provider checkout;
* `POST /provider/webhook` verifies your provider and loads the attempt mapping
  from D1; and
* a Queue consumer signs and retries the event delivery to Shoppex; and
* `POST /.well-known/shoppex-payment-adapter` answers Shoppex's signed,
  non-mutating conformance probes.

Hono is only the HTTP router. You can implement the same contract with Elysia,
Fastify, Go, or another Worker-compatible runtime.

## Create Cloudflare resources

Create one D1 database, one delivery Queue, and its dead-letter Queue:

```bash theme={"system"}
bunx wrangler d1 create shoppex-external-payment-adapter
bunx wrangler queues create shoppex-external-payment-events
bunx wrangler queues create shoppex-external-payment-events-dlq
```

Copy the returned D1 ID into `wrangler.jsonc`, then apply the included migration:

```bash theme={"system"}
bunx wrangler d1 migrations apply shoppex-external-payment-adapter --remote
```

The D1 row stores only the binding required after the redirect:
`provider_reference`, `attempt_id`, `event_url`, exact amount, and currency.

## Adapt the provider calls

In `src/index.ts`, replace the example `fetch(PROVIDER_SESSION_URL, ...)` body
with your provider's create-session API. Keep these rules:

1. Send `attempt_id` as the provider idempotency key or metadata.
2. Store the provider's stable reference with the Shoppex event URL.
3. Return an HTTPS `checkout_url`.
4. Verify the provider webhook over its **raw request body** before parsing it.
5. Read the settled amount and currency from the verified provider event and
   require both to match the D1 session before notifying Shoppex. Never copy
   the expected D1 amount into the event as if the provider observed it.
6. Map only final provider settlement to `payment.succeeded`. Do not treat a
   browser return URL as proof of payment.

The example provider response expected by the starter is:

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

The example webhook shape is:

```json theme={"system"}
{
  "id": "provider_event_987",
  "provider_reference": "provider_session_123",
  "status": "succeeded",
  "amount_minor": 4999,
  "currency": "EUR",
  "occurred_at": "2026-08-14T12:32:04Z"
}
```

Replace its `x-provider-signature` HMAC verifier with the provider's official
verification algorithm. Preserve the provider's stable event ID: the Worker
uses it as the Shoppex `webhook-id`, so provider redelivery and Queue retries
deduplicate to the same payment decision. Do not weaken or remove verification
in production. If delivery exhausts its ten retries, Cloudflare moves the
message to `shoppex-external-payment-events-dlq` for investigation and replay;
it is not silently discarded.

## Deploy and configure secrets

Add the provider credentials first:

```bash theme={"system"}
bunx wrangler secret put PROVIDER_API_KEY
bunx wrangler secret put PROVIDER_WEBHOOK_SECRET
bunx wrangler deploy
```

Copy the deployed session URL, for example:
`https://shoppex-external-payment-adapter.example.workers.dev/sessions`.

In Shoppex, open **Settings → Payments → External**, select **Add adapter**, and
enter that URL. Shoppex shows a `whsec_...` shared secret once. Save it in the
Worker:

```bash theme={"system"}
bunx wrangler secret put SHOPPEX_SHARED_SECRET
bunx wrangler deploy
```

After the secret is deployed, select **Test** on the adapter. Shoppex derives
the conformance URL from the session URL, so you do not configure a second URL.
Enable the adapter only after all six contract checks pass and you have tested
the real provider in its sandbox.

## Signed Shoppex contract

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 is:

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

The session request includes exact integer minor units:

```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://shoppex-api.example/v1/external-payment-adapters/ADAPTER_ID/events"
  }
}
```

For example, `4999 EUR` means `€49.99`. Never convert this through a floating
point amount in the Worker. Shoppex generates the real `event_url`; your
adapter must store and call it unchanged.

## Test before going live

In **Settings → Payments → External**, select **Test** next to the adapter.
Shoppex sends signed challenges to
`/.well-known/shoppex-payment-adapter` and checks:

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

This test is safe to run on a configured adapter: it does not create a provider
checkout, call the provider, create a Shoppex payment attempt, or touch an
invoice. A green result proves that the Worker speaks the Shoppex contract. It
does **not** prove that the provider API mapping or provider webhook verifier is
correct.

Run the starter checks:

```bash theme={"system"}
bun run typecheck
bun run test
```

Then test these failure cases against a sandbox provider:

1. changed Shoppex body with the original signature → `401`;
2. provider webhook with a wrong signature → `401`;
3. unknown provider reference → `404`;
4. duplicate successful webhook → one completed Shoppex order;
5. Shoppex event endpoint temporarily returns `500` → Queue retries, then moves
   an exhausted delivery to the dead-letter Queue;
6. buyer starts a second attempt before the first session returns → the late
   first response cannot become current; and
7. wrong provider amount or currency → Worker returns `409`, sends no event,
   and the invoice remains unpaid.

## Restrict an adapter to specific currencies

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

A restricted adapter is hidden at checkout for any other currency, and
`createSession` refuses it server-side with a `400`. Your Worker therefore never
receives a session request in a currency you excluded, and does not need its own
currency gate for this — keep the amount and currency check against the provider
response, which guards a different failure.

## Session claims and quarantined attempts

The starter Worker claims each Shoppex attempt in D1 **before** it calls your
provider, and only the request that wins that insert calls it. A repeat of the
same signed request replays the stored session; a different payload for the same
attempt is refused with `409`.

If the provider call ends without a usable answer — timeout, network error,
`5xx`, or a `2xx` body the Worker cannot parse — the claim is **not** released.
None of those outcomes proves the provider created nothing, and retrying could
open a second checkout the buyer might also pay. The row is marked
`status = 'failed_unknown'` and further requests for that attempt answer `409`.

Only an allowlisted validation rejection (`400`, `401`, `403`, `404`, `422`)
releases the claim, because those happen before a session exists. Every other
non-2xx — redirects, `408`, `409`, `425`, `429` — is treated as an unknown
outcome and quarantined too.

### Recovering a quarantined attempt

<Warning>
  Check your provider's dashboard for a session bound to that `shoppex_attempt_id`
  before clearing anything. Deleting the row without checking is what lets a second
  provider session appear.
</Warning>

* A session **does** exist: complete the row instead of deleting it, so the
  stored reference matches what the provider will send events for.
  ```sql theme={"system"}
  UPDATE payment_sessions
  SET status = 'completed', provider_reference = ?, checkout_url = ?
  WHERE attempt_id = ? AND status = 'failed_unknown';
  ```
* No session exists: delete the row. Shoppex's next attempt then claims cleanly.
  ```sql theme={"system"}
  DELETE FROM payment_sessions WHERE attempt_id = ? AND status = 'failed_unknown';
  ```

The buyer is never stuck meanwhile: Shoppex can start a new payment attempt,
which carries a new `attempt_id` and therefore its own claim.

## Version-one limits

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

These limits fail closed. Add a versioned contract extension when your provider
needs a new lifecycle instead of inferring missing values.
