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.
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.
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 instead when you only
need instructions, a static redirect, or human confirmation.
How the flow works
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:
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:
Copy the returned D1 ID into wrangler.jsonc, then apply the included migration:
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:
- Send
attempt_id as the provider idempotency key or metadata.
- Store the provider’s stable reference with the Shoppex event URL.
- Return an HTTPS
checkout_url.
- Verify the provider webhook over its raw request body before parsing it.
- 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.
- 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:
The example webhook shape is:
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.
Add the provider credentials first:
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:
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
header format:
The signed string is:
The session request includes exact integer minor units:
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:
- the versioned request and challenge response;
- rejection of a changed Shoppex signature;
- signatures and attempt binding on event samples;
- detection of a wrong amount;
- one stable event identity across a duplicate delivery; and
- 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:
Then test these failure cases against a sandbox provider:
- changed Shoppex body with the original signature →
401;
- provider webhook with a wrong signature →
401;
- unknown provider reference →
404;
- duplicate successful webhook → one completed Shoppex order;
- Shoppex event endpoint temporarily returns
500 → Queue retries, then moves
an exhausted delivery to the dead-letter Queue;
- buyer starts a second attempt before the first session returns → the late
first response cannot become current; and
- 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
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.
- A session does exist: complete the row instead of deleting it, so the
stored reference matches what the provider will send events for.
- No session exists: delete the row. Shoppex’s next attempt then claims cleanly.
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.