If you build your own card form instead of using our hosted components, the card details never travel to us in the clear. You encrypt them in the shopper's browser using JSON Web Encryption (JWE), then pass the resulting ciphertext to your server and on to the Woosa Payments API.
This page covers when to choose JWE, how to set it up, and what to watch out for in production.
Should you use this?
Most integrations should not. Reach for JWE only when you have a concrete reason to control the card form yourself.
Approach
PCI scope
Use when
Drop-in
SAQ A
You want a working checkout fast and are happy with our UI.
Components
SAQ A
You want your own layout, but our fields. Card data enters an iframe we host; your page never touches it.
JWE
SAQ A-EP or higher
You need the raw PAN in your own frontend, for example to run your own risk scoring, route between processors or BIN-check before submission.
The distinction that matters: with Components, the card number is typed into an iframe served from our domain, so your page is out of scope for the card data itself. With JWE, your JavaScript reads the PAN. That pulls your checkout page into PCI scope, and you become responsible for the integrity of every script loaded on it.
Before choosing JWE, confirm with your QSA or acquirer which SAQ applies to you. Moving from SAQ A to SAQ A-EP is a material increase in compliance obligation and it is not reversible without re-engineering your checkout.
How it works
Shopper's browser Your server Woosa Payments
───────────────── ─────────── ──────────────
1. Shopper types card
2. Encrypt with your
public key(JWK)
3. POST jwe ────────────────────►
4. POST /payments ──────►
5. Decrypt, authorise
◄─────────────────────── 6. Result
◄─────────────────────────────
The keypair is asymmetric. You hold the public half; the private half never leaves our infrastructure. Once a payload is encrypted, neither you nor your server can read it back — which is the point. If you need the PAN for your own risk logic, capture what you need before you encrypt.
Prerequisites
An active Woosa Payments merchant account.
API credentials with the Checkout role.
The Manage API credentials permission on your Woosa Payments dashboard user.
A working server-side /payments call. Get an unencrypted test payment working first, then add encryption, debugging both at once is unpleasant.
1. Download your X.509 certificate
Request the X.509 certificate from your Account Manager, we'll send it to you manually after confirming your PCI scope.
You will get a PEM file that looks like this:
-----BEGINCERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAOSc...
-----ENDCERTIFICATE-----
The certificate is public. It is safe to serve it to browsers, embed it in your bundle, or commit it. That said, fetching it from your own backend at runtime is better practice: it lets you rotate without a redeploy.
Test and live accounts have different certificates. A very common failure is encrypting with the test certificate and posting to the live endpoint. The resulting error is not obvious, so check this first when decryption fails.
2. Import the certificate as a public key
We recommend jose. It is actively maintained, has no dependencies, and works in browsers, Node and edge runtimes.
Import the key once and reuse it. Re-importing on every keystroke or every submission is wasteful and will show up as jank on slower devices.
3. Build the payload
Collect the card fields into a plain object and add a timestamp.
js
const cardDetails ={
number:'4111111111111111',
expiryMonth:'03',
expiryYear:'2030',
cvc:'737',
generationtime:newDate().toISOString()
};
Field
Format
Notes
number
String, digits only
Strip spaces and dashes before encrypting.
expiryMonth
String, 01–12
Zero-padded. 3 is rejected; 03 is accepted.
expiryYear
String, four digits
2030, not 30.
cvc
String
Three digits, or four for American Express.
generationtime
ISO 8601 UTC
Must be recent. See below.
generationtime exists to stop replay of a captured payload. We reject blobs whose timestamp is too far from our server clock. Generate it at encryption time from the browser's clock, and encrypt at submission rather than on page load — a shopper who fills the form and then goes to make coffee will otherwise get a rejection on submit.
Do not log cardDetails, do not put it in application state that a debugger or error reporter might serialise, and do not send it to Sentry or similar. Session replay tools are the usual culprit here: audit your tag manager before go-live.
The result is a compact-serialisation JWE: five base64url segments separated by dots. It is safe to put in a JSON body.
Algorithms. Use RSA-OAEP-256 for key wrapping and A256GCM for content encryption. Older RSA-OAEP and A128CBC-HS256 are still accepted for backwards compatibility but should not be used in new integrations.
Encrypt everything in one blob. Encrypt the full object as a single payload rather than encrypting each field separately. Per-field encryption produces smaller, more uniform ciphertexts, which is exactly the shape an attacker wants for analysis.
5. Send to your server
js
awaitfetch('/api/checkout/pay',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
jwe,
amount:4999,
currency:'EUR',
reference: orderId
})
});
Never send the amount, currency or reference from the client without validating them server-side against your own order record. The encrypted blob protects the card data; it does nothing for the rest of the request.
6. Make the payment
From your server, pass the blob through in the paymentMethod object:
Handle the response exactly as you would for any other card payment, including the 3D Secure action object when one is returned. JWE changes how card details reach us; it does not change authentication, and SCA still applies.
Testing
Use your test certificate with the standard test cards.
Card
Number
Result
Visa
4111 1111 1111 1111
Authorised
Mastercard
5555 4444 3333 1111
Authorised
Visa
4000 0000 0000 0002
Refused
Visa
4212 3456 7890 1237
3D Secure challenge
Any future expiry date and any CVC of the correct length will work in test.
Verify the blob decrypts before you go live. Encrypt a known test card, submit it, and confirm the payment authorises. A malformed JWE fails at decryption with a generic error, so proving the round trip on a card you control saves time later.
Troubleshooting
Unable to decrypt the provided data Almost always a certificate mismatch: test certificate against live endpoint, or a certificate from a different merchant account. Confirm the certificate you imported belongs to the credential making the API call.
Encrypted data is no longer validgenerationtime is outside the accepted window. Move encryption to the submit handler. If it persists across many shoppers, suspect device clock drift rather than your code.
Invalid card details on a card you know is good Check field formats. expiryMonth unpadded and expiryYear in two-digit form are the two most frequent causes. Also check that you have stripped spaces from the card number — most input masks insert them.
Encryption succeeds locally, fails in production Compare the certificate loaded in each environment. If you fetch it at runtime, check that your production endpoint is returning the PEM intact — some proxies mangle the line breaks, which breaks importX509.
Cannot read properties of undefined from jose in the browserimportX509 and CompactEncrypt().encrypt() both return promises. Missing an await gives this error a few lines later, where it is hard to trace back.
Security checklist before go-live
Content Security Policy restricts which scripts can run on the checkout page.
Subresource Integrity on any externally hosted script.
No session replay or error reporting tool captures form field values.
Card details are not written to localStorage, sessionStorage or any state store that persists.
The card object goes out of scope immediately after encryption.
Amount, currency and reference are validated server-side.
Certificate rotation does not require a frontend redeploy.