Skip to main content
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.
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.

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 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. 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.
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.
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.
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.

Sign and verify every request

Both directions use the Standard Webhooks header format:
The signed string joins three parts with periods:
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:
signatures.ts
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:
Every key is always present. Two of them accept 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:
Shoppex validates this answer strictly and reports one generic error for every rejection. Obey these limits: 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:
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:

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. 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:
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.
If a provider session exists, complete the row instead of deleting it. The stored reference must match the events that the provider will send:
If no provider session exists, delete the row. The next Shoppex attempt then claims cleanly:

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.
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: 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:
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. 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.
Each 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.
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.