v1.0.0

Woosa Payments: A Step-by-Step Integration Guide

Learn how to process payments with Adyen via Woosa Payments

Contents

Who this guide is for

You are building, or helping build, an online shop that is not WooCommerce (a custom stack, a headless frontend, a different framework etc.), and you want to accept payments through Woosa Payments.

If your shop runs on WooCommerce, you do not need any of this. There is a ready-made plugin. Install it, configure it, done. This guide only matters for shops that are not WooCommerce.

You do not need to be a payments expert to follow this. Wherever a technical term shows up, it is explained in plain words the first time, and there is a glossary at the end. You will need someone who can write a bit of backend code to actually wire it up, but this guide is meant to make the whole journey understandable before a single line is written.

The big picture (read this first)

Woosa Payments sits between your shop and Adyen (the actual payment processor). You never talk to Adyen directly. You talk to a Woosa server called the Midlayer, and the Midlayer talks to Adyen for you.

There are two base addresses for the Midlayer:

Environment Base URL
Development (for testing) https://midlayer-dev.woosa.nl
Production (real money) https://midlayer.woosa.nl

Here is the part that trips people up, so let us be clear about it up front. The connection works in two directions:

  1. You call Woosa. Most of the time your shop sends requests to the Midlayer: "create a payment", "refund this", "list my stores". This is the normal, outbound direction.
  2. Woosa calls you. During setup, the Midlayer calls back to your shop to check your credentials and to hand you a secret key. Your shop has to expose a few small endpoints that the Midlayer can reach.

That second direction is the whole reason a non-WooCommerce integration takes a little extra work. A WooCommerce shop already exposes these endpoints out of the box. Yours does not yet, so you should build them first. How to do that is described in Part 1 below.

A useful way to picture the full journey:


[ PREP ] Build 3 small endpoints on your shop that Woosa can call back to.
|
[ REGISTER ]  Tell Woosa your shop exists. Woosa calls you back and gives you a secret key.
|
[ SIGN ] From now on, every request you send should be "signed" with that secret key.
|
[ CONNECT ] Create or link your existing Woosa Payments UI account (email + verification code).
|
[ SET UP ] Pick your legal entity, create a store, complete onboarding (ID checks).
|
[ CONFIGURE ] Choose which payment methods (iDEAL, cards, Klarna, etc.) are active.
|
[ TEST ] On the dev environment, run real test payments end-to-end.
|
[ GO LIVE ] Onboard the real legal entity in production and switch the base URL.
|
[ TAKE MONEY ] Create payments, handle any extra shopper steps, capture, refund.

We will walk through each block in order.

Before you start: what you need

Part 1: Build the endpoints Woosa will call back (non-WooCommerce only)

You need to expose three endpoints on your own shop. Build these before you try to register, because registration will immediately call two of them.

The paths use the WooCommerce naming on purpose. The Woosa server expects these exact paths and contracts, because it treats every shop as if it were WooCommerce.

1a. “Give me your settings” endpoint

GET /wp-json/wc/v3/settings/general

What it is for: before your shop is registered, Woosa calls this to validate that the key and secret you gave are real.

Rules:

Example response (first time, no secret yet):

[
  {
    "id": "woosa_secret",
    "value": ""
  }
]

1b. “Save a setting” endpoint

POST /wp-json/wc/v3/settings/general/batch

What it is for: once Woosa has confirmed your credentials and registered your shop, it generates a secret key for your shop and pushes it to you through this endpoint. You store it. From that moment on, this woosa_secret is what you use to sign every request (Part 3).

Rules:

Example request Woosa sends you:

{
  "update": [
    {
      "id": "woosa_secret",
      "value": "8caa59a1c330f23d55de3bdd339a1c330f23d55"
    }
  ]
}

Store the secret key somewhere safe and permanent (encryption is recommended). This is the single most important secret in the whole integration. If you lose it, you cannot sign requests, and nothing works.

1c. “Onboarding return” endpoint

GET /wp-json/woosa-payments/onboarding-redirect

What it is for: later, the merchant gets sent to an Adyen page to prove their identity (see Part 5). When they finish, they get bounced back to this URL. Its only job is to redirect the merchant to the right page in your own shop's admin (for example, back to a "Payments settings" screen).

Rules:

That is the full extent of the extra work. Three small endpoints. Two of them are essentially a tiny key-value store guarded by Basic Auth, and the third is a redirect. Build these, then move on.

Part 2: Register your shop

Now you tell Woosa that your shop exists.

POST /woocommerce/shops

Body:

{
  "url": "https://myshop.example.com/",
  "key": "ck_781d7a45573443cba7e263901c0c5da511e98949d1",
  "secret": "cs_029425b4341cce8caa5de3bdd339a1c330f23d55",
  "version": "wc/v3",
  "auth": "basic"
}

What happens behind the scenes (this is the "handshake"):

  1. Woosa receives your registration.
  2. Woosa calls your GET settings endpoint from Part 1a, using the key and secret you just sent, to confirm they work.
  3. If that succeeds, Woosa generates your woosa_secret and pushes it to your POST batch endpoint from Part 1b.
  4. Your shop stores the secret.

A successful registration returns 204 No Content (an empty success). If you get a 401, it usually means Woosa could not authenticate against your callback endpoints, so double-check Part 1. If you get a 400, something in the body is wrong.

After this step you hold a woosa_secret. Everything from here on is signed with it.

Part 3: How to sign every request (the signature)

From now on, almost every request must carry a signature. Think of a signature as a tamper-proof wax seal. It proves two things to Woosa: that the request really came from you, and that nobody changed it in transit. It is recomputed fresh for every single request.

You will send three headers on signed requests:

Header Value
x-woosa-domain your shop domain, e.g. myshop.example.com
x-woosa-signature the signature you compute below
x-woosa-signature-type plugin (this is the documented default)

How the signature is built

You put three pieces of information into a small package, scramble it with your secret using a standard one-way function (HMAC-SHA256), and stick a timestamp on the front. The three pieces are:

Then:

  1. Build a JSON object { "uri": ..., "body": ..., "time": ... }.
  2. Turn it into HMAC-SHA256 using your woosa_secret as the key, and Base64-encode the result. That is your dataSignature.
  3. The final header value is time + a dot + dataSignature.

The end result looks like this:

1636117273.MNw1Rd5O0evUmwXy85j0ca2bg8SDg/Xm4WfA3LdI5gg=

PHP example

$data = [
  'uri' => $uri, // e.g. "/woocommerce/adyen/stores" (path + query, starts with /)
  'body' => $body, // the exact JSON string you send; "" for GET
  'time' => time(),
];

$dataSignature = base64_encode(hash_hmac('sha256', json_encode($data), $secret, true)); // $secret = your woosa_secret
$finalSignature = sprintf('%s.%s', $time, $dataSignature); // send $finalSignature in the x-woosa-signature header

JavaScript / Node.js example (mind the slashes)

There is one subtle trap. PHP's json_encode escapes forward slashes (it writes \/ instead of /), and JavaScript's JSON.stringify does not. If your signature is generated in JavaScript, you must add that escaping by hand, otherwise the two sides compute different signatures and every request fails with 401.

const crypto = require('crypto');

const time = Math.floor(Date.now() / 1000);

const data = {
  uri: uri,   // path + query, starts with /
  body: body, // exact request body string; "" for GET
  time: time,
};

// JSON.stringify does NOT escape "/". Replace manually to match PHP's json_encode.
const dataToSign = JSON.stringify(data).replace(/\//g, '\\/');

const dataSignature = crypto
  .createHmac('sha256', secret) // secret = your woosa_secret
  .update(dataToSign)
  .digest('base64');

const finalSignature = `${time}.${dataSignature}`;

The number one cause of "it says 401 and I do not know why": the string you signed does not exactly match the request you sent. The body you sign must be byte-for-byte the same text you put in the request. The uri you sign must include the query string and start with /. And in JavaScript, remember the slash escaping.

Part 4: Connect your merchant account

Now you link a real account, identified by an email address. This is a two-step, signed exchange.

4a. Ask for a verification code

POST /email-verification-messages

Body:

{ "email": "merchant@example.com" }

This emails a short code to the merchant. Success is 204 No Content.

4b. Create or connect your Woosa Payments UI account to the shop

PUT /woocommerce/adyen/backoffice-user

Body:

{ "email": "merchant@example.com", "code": "123456" }

The code is what arrived by email. Please note, that code's lifetime is only 1 minute! On success (204), the account is connected. From here, the account-level actions (contractors, stores) become available.

Part 5: Set up a legal entity and a store, then onboard

Before you can take money you need a store attached to a contractor. A contractor is the legal entity that actually gets paid: a company or an individual. Adyen has to know who is receiving the funds, which is why this exists.

5a. See your contractors

GET /woocommerce/adyen/backoffice-user-contractors

Returns a list like:

[
  {
      "id": "3c55be36-0dae-4be8-8354-0ce07a8bb101",
      "name": "My Company B.V.",
      "country": "NL",
      "type": "organization"
  }
]

5b. Set the current contractor (required only if you want to bind an existing contractor, ignore it if you get an empty list in 5a)

PUT /woocommerce/adyen/contractor

Body:

{ "contractorId": "3c55be36-0dae-4be8-8354-0ce07a8bb101" }

This call is mandatory if you want to access contractor related information. Skipping this step is the single most common cause of confusing errors.

For example, listing stores (GET /woocommerce/adyen/stores) will either fail with an error if there is no connected contractor, or silently return the stores that belong to the wrong contractor, if your account has more than one and if another contractor was connected to this shop before.

5c. Create a store

POST /woocommerce/adyen/stores

You describe both the contractor and the store. If the contractor does not exist yet, it is created automatically during this call and connected to the shop.

Please note that you should not provide real company information on DEV environment.

{
  "contractor": {
    "type": "organization",
    "name": "My Company B.V.",
    "owner": { "firstName": "John", "lastName": "Doe" },
    "country": "NL"
  },
  "store": {
    "label": "My Store",
    "shopperStatement": "My Store NL",
    "industryCode": "5411",
    "phone": "+31612345678",
    "currency": "EUR",
    "address": {
      "country": "NL",
      "city": "Amsterdam",
      "stateOrProvince": "",
      "line1": "Keizersgracht 1",
      "line2": "",
      "line3": "",
      "postalCode": "1012AB"
    }
  }
}

A couple of the fields deserve a plain-language note:

The response gives you a store id, for example ST32CSL223229G5NV6J3W4Z22. Write this down. It goes into the URL of most payment calls from here on.

You can list existing stores at any time with GET /woocommerce/adyen/stores — as long as the current contractor is set correctly first.

5d. Complete onboarding (identity and compliance checks)

Before real payments can flow, the merchant has to pass Adyen's checks (proof of identity, business details, accepting terms). This is called onboarding, and it is often referred to as KYC ("Know Your Customer").

Check where things stand:

GET /woocommerce/adyen/onboarding-status

A response tells you the status and, if something is missing, exactly what:

{
  "status": "verification-required",
  "errors": [
    {
      "problem": "'organization.doingBusinessAs' was missing.",
      "actions": ["Add 'organization.doingBusinessAs' to legal entity"]
    },
    {
      "problem": "Terms Of Service forms are not accepted.",
      "actions": ["Accept TOS"]
    }
  ]
}

To let the merchant fix these, generate a link and send them to it:

POST /woocommerce/adyen/onboarding-links

Response:

{ "uri": "https://onboarding-example.com/check-page/..." }

Redirect the merchant to that uri. They fill in whatever Adyen needs. When they finish, Adyen returns them to your onboarding-redirect endpoint from Part 1c, which sends them back into your admin. Re-check the status until it comes back clear.

Part 6: Choose which payment methods are active

6a. See what is available

GET /woocommerce/adyen/stores/{store_id}/checkout-payment-methods

Optional query parameters let you narrow the list:

The response has two parts: paymentMethods (everything available to offer, such as iDEAL, cards, Klarna, Apple Pay) and storedPaymentMethods (cards or methods this shopper saved earlier, if you passed a shopper).

6b. Turn methods on or off

PUT /woocommerce/adyen/stores/{store_id}/payment-methods

You send the full list of methods you want active. This replaces the previous list, it does not add to it. So always send the complete set you want enabled.

A minimal example:

[
  { "type": "ideal" },
  { "type": "scheme" },
  { "type": "applepay", "applePay": { "domains": ["https://myshop.example.com"] } }
]

Some methods need extra configuration. You can find more details here. The response tells you, per method, whether it came out valid or returned an error you need to fix.

Part 7: Take a payment (the core loop)

This is the heart of it. A payment sometimes finishes in one step, and sometimes needs a second step because the shopper has to do something (approve in their banking app, pass a card security check, get redirected and come back). Your code should be ready for both.

7a. Create the payment

POST /woocommerce/adyen/stores/{store_id}/payments

Key fields:

Plus optional but commonly used fields: shopperReference (a stable id for the customer, needed if you want to save their method), shopperEmail, billingAddress, lineItems (required for methods like Klarna), browserInfo (needed for card security checks), and storePaymentMethod: true if you want to save the method for next time.

A trimmed iDEAL example:

{
  "channel": "web",
  "returnUrl": "https://myshop.example.com/checkout/order-received/592/",
  "amount": { "currency": "EUR", "value": 2800 },
  "paymentMethod": { "type": "ideal" },
  "metadata": { "wc_order_id": 592 }
}

Tip: put your own order id in metadata (for example wc_order_id). Woosa echoes metadata back to you later, so this is how you match a payment to the right order in your system.

7b. Read the result and react

The response always includes a resultCode. The two cases to handle:

Because the exact response shape varies by payment method, treat resultCode as your primary signal, and lean on Adyen's own documentation for method-specific fields.

7c. Finish the extra step (only when there was an action)

After the shopper completes the redirect or challenge, the frontend gives you back some details. Send them here to finalise:

POST /woocommerce/adyen/stores/{store_id}/payment-details
{
  "details": { "redirectResult": "X3XtfGC9!H4sIAAAAAAAA..." },
  "metadata": { "wc_order_id": 593 }
}

Now you get a final resultCode (for example Authorised) with the confirmed amount and pspReference. This time the payment really is settled at the authorisation stage.

Part 8: After the payment (capture, refund, cancel, status)

These all use the pspReference from the payment, not the store id.

Capture

POST /woocommerce/adyen/payments/{psp_reference}/captures

Body:

{ "amount": { "currency": "EUR", "value": 2800 } }

Authorise vs capture, in plain words: when a payment is authorised, the money is reserved but not actually moved. Capturing is what pulls the money in. Many setups capture automatically, but if yours is set to manual capture you must call this, for example when you ship the goods. You can capture the full amount or a smaller part.

Refund

POST /woocommerce/adyen/payments/{psp_reference}/refunds

Body:

{ "amount": { "currency": "EUR", "value": 5000 } }

Gives money back to the shopper. Can be full or partial.

Cancel

POST /woocommerce/adyen/payments/{psp_reference}/cancels

Cancels a payment that was authorised but not yet captured. No body needed. Use this to release a reservation you are not going to charge (for example an order that fell through before shipping).

Get status updates

GET /woocommerce/adyen/updated-payments?after=2024-07-01 10:00:00

Payment statuses change over time (an authorisation gets captured, a refund settles, a chargeback arrives). This endpoint returns every payment whose status changed in a time window. Poll it regularly (for example every hour) using after (required) and optionally before, and update the matching orders in your system using the metadata you attached earlier.

Part 9: Housekeeping

Remove a saved payment method

POST /woocommerce/adyen/stores/{store_id}/unsubscribe-requests
{
  "storedPaymentMethodId": "SGJC5J9LPTCT8W75",
  "shopperReference": "32eeae5a-cc41-4bd3-967f-862c82bfec22"
}

Deletes a card or method a shopper had saved. Use this when a customer removes a saved card, or cancels a subscription.

GDPR: erase a shopper's data

POST /woocommerce/adyen/payments/{psp_reference}/subject-erasure-requests

Submits a request to erase the personal data tied to a specific payment. This is what you call to honour a "right to be forgotten" request.

Part 10: Go live

Do not point your integration at production on day one. Prove it out on the dev environment first, with a throwaway legal entity, and only onboard the real one once you have watched real test payments succeed end to end.

Step 1: Onboard a dummy legal entity on dev

On https://midlayer-dev.woosa.nl, walk through Part 5 with a test legal entity: made-up (but validly formatted) company details, a test contractor, and a test store. Complete onboarding (Part 5d) for this dummy entity so the store is actually able to take payments — Adyen's test environment will accept it without real KYC documents.

This dummy entity's only job is to let you exercise the full flow without touching anything real. Do not reuse it once you go live.

Step 2: Run test payments

With the dummy entity's store, work through the core loop in Part 7 and Part 8 using Adyen's test card numbers and test iDEAL/redirect flows. At minimum, confirm your integration correctly handles:

Only move on once every one of these behaves the way your shop expects, and your order statuses update correctly in each case.

Step 3: Onboard the live legal entity

Once the dev tests are clean, repeat Part 5 against production (https://midlayer.woosa.nl), this time with the merchant's real legal entity, real contractor details, and real store. This is the entity that goes through genuine KYC — the merchant will need to provide real identity and business documents during onboarding (Part 5d).

Remember: production has its own registration (Part 2) and its own woosa_secret. Dev and production are entirely separate merchant accounts with separate credentials — a dev secret will not sign valid requests against production, and vice versa.

Step 4: Switch over

Go-live checklist, in one line: dummy legal entity on dev → full test-payment run-through → real legal entity onboarded on production → switch the base URL and secret → one real test payment → live.

Quick reference: all endpoints

Base URL is https://midlayer.woosa.nl (or the -dev variant for testing). Every path below is signed except Register.

For full technical details on every field and parameter, see the API doc at wiki.woosashop.nl/rest-api-docs/woosa-payments.

Setup and account

Do this Method + path
Register your shop (unsigned) POST /woocommerce/shops
Send an email verification code POST /email-verification-messages
Connect the account PUT /woocommerce/adyen/backoffice-user
List contractors GET /woocommerce/adyen/backoffice-user-contractors
Set current contractor (required) PUT /woocommerce/adyen/contractor
List reference: industry codes GET /woocommerce/adyen/industry-codes
List reference: countries GET /woocommerce/adyen/countries

Stores and onboarding

Do this Method + path
List stores GET /woocommerce/adyen/stores
Create a store POST /woocommerce/adyen/stores
Check onboarding status GET /woocommerce/adyen/onboarding-status
Create an onboarding link POST /woocommerce/adyen/onboarding-links

Payment methods

Do this Method + path
See available methods GET /woocommerce/adyen/stores/{store_id}/checkout-payment-methods
Enable/disable methods PUT /woocommerce/adyen/stores/{store_id}/payment-methods

Payments

Do this Method + path
Create a payment POST /woocommerce/adyen/stores/{store_id}/payments
Finish an extra shopper step POST /woocommerce/adyen/stores/{store_id}/payment-details
Capture POST /woocommerce/adyen/payments/{psp_reference}/captures
Refund POST /woocommerce/adyen/payments/{psp_reference}/refunds
Cancel POST /woocommerce/adyen/payments/{psp_reference}/cancels
Poll for status changes GET /woocommerce/adyen/updated-payments
Remove a saved method POST /woocommerce/adyen/stores/{store_id}/unsubscribe-requests
GDPR erasure POST /woocommerce/adyen/payments/{psp_reference}/subject-erasure-requests

Endpoints your shop must expose (non-WooCommerce only)

Woosa calls this on your shop Method + path
Read settings (Basic Auth) GET /wp-json/wc/v3/settings/general
Save settings (Basic Auth) POST /wp-json/wc/v3/settings/general/batch
Onboarding return (no auth) GET /wp-json/woosa-payments/onboarding-redirect

Common mistakes and how to avoid them

Glossary