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
How the flow works
- The buyer selects your external payment method at checkout.
- Shoppex creates a payment attempt and sends a signed
payment.session.createrequest to your adapter. - Your adapter calls the provider and creates a checkout.
- Your adapter answers with
checkout_urlandprovider_reference. - Shoppex redirects the buyer to that checkout URL.
- The provider sends its own signed webhook to your adapter after settlement.
- Your adapter verifies that webhook and sends a signed
payment.succeededevent to Shoppex. - Shoppex matches the attempt, the amount, and the currency. Then it completes the order and the fulfillment.
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.
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.
The host must serve public HTTPS. Redirects to private networks are refused.
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.
Sign and verify every request
Both directions use the Standard Webhooks header format:- 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.
- Shoppex shows the shared secret with a
whsec_prefix. Remove that prefix and Base64-decode the remainder. Those bytes are the HMAC key. - Refuse a timestamp that is more than 5 minutes away from the current time. Shoppex applies the same window to your events.
- Compare the signatures in constant time. The
webhook-signatureheader can hold more than one space-separated version. Accept the request when onev1,entry matches. - 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.
- 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.
signatures.ts
Create the provider session
Shoppex sends this request to your session endpoint and waits 15 seconds:null:
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:
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:
- Send
attempt_idas the idempotency key or the metadata of the provider. - Store the provider reference together with the Shoppex
event_url, the exact amount, and the currency. - Set a timeout below 15 seconds. A slower provider must fail, not hold the buyer.
- Do not follow redirects to the provider. A redirect can send your API key to another host.
- 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: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:
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:
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. Afailed_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:
Report the payment to Shoppex
Your provider webhook handler verifies the provider signature, loads the stored session byprovider_reference, and compares the settled values. Then it sends
a signed event to the stored event_url.
type field accepts payment.processing, payment.succeeded, and
payment.failed. occurred_at is optional and accepts null.
Seven rules apply to this direction:
- 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.
- 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. - If the amount or the currency does not match your stored session, send no event. Answer your provider with HTTP 409.
- Map only final provider settlement to
payment.succeeded. A browser return URL is not proof of payment. - 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. - Do not follow redirects on the event URL. Any 2xx page anywhere then satisfies the delivery.
- Deliver from a queue or a retry loop. A provider webhook must not wait for Shoppex.
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 throughPOST /.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:
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.
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.
raw_body is the exact string that you signed. Shoppex verifies each
signature against that string.
Configure the adapter in Shoppex
1
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.2
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.3
Store the shared secret
Save that secret in your service and deploy again. The adapter cannot verify a
Shoppex request before this step.
4
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.
Test before going live
The Test button sends the signed challenges and checks six things:- the versioned request and the challenge response
- the rejection of a changed Shoppex signature
- the signatures and the attempt binding on the event samples
- the detection of a wrong amount
- one stable event identity across a duplicate delivery
- the 500 ms timeout deadline of Shoppex
- A changed Shoppex body with the original signature returns 401.
- A provider webhook with a wrong signature returns 401.
- An unknown provider reference returns a retryable status and creates no event.
- A duplicate successful webhook results in one completed Shoppex order.
- A Shoppex event endpoint that returns 500 causes a retry, then a dead-letter entry.
- A second buyer attempt before the first session answer cannot make the late first answer current.
- 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