# Implementing SuperPay through the Webhook integration

How to connect a webshop that is not Shopify — Magento, WooCommerce, a bespoke
platform — to SuperPay. SuperPay calls a set of HTTP endpoints you host; you keep
your own cart, your own orders and your own fulfilment.

Audience: a developer building the shop side. Everything here is the contract
SuperPay actually implements; where a schema is involved, the machine-readable
truth is served live (see [Endpoint reference](#4-endpoint-reference)).

---

## 1. Overview

SuperPay renders the checkout. Your shop stays the system of record.

```
Shopper                SuperPay                     Your shop
   │                       │                             │
   │  opens checkout       │                             │
   ├──────────────────────►│   AdjustCart                │
   │                       ├────────────────────────────►│  create/update cart
   │                       │◄────────────────────────────┤  cart state
   │                       │   GetDeliveryMethods        │
   │                       ├────────────────────────────►│
   │  pays                 │   PlaceOrder                │
   ├──────────────────────►├────────────────────────────►│  CREATE ORDER, unpaid
   │                       │◄────────────────────────────┤  orderId + URLs
   │                       │                             │
   │       ┌───────────────┴───────────────┐             │
   │       │  payment provider (PSP)       │             │
   │       │  authorizes the money         │             │
   │       └───────────────┬───────────────┘             │
   │                       │   PaymentAuthorized         │
   │                       ├────────────────────────────►│  MARK ORDER PAID
   │                       │◄────────────────────────────┤  200
   │  returns to continueURL                             │
   ├────────────────────────────────────────────────────►│  thank-you page
```

Two things are easy to get wrong and expensive to discover later:

**Your order is created before the money exists.** `PlaceOrder` must create a real
order in an unpaid state and return its id. That id is what the payment is created
against and what every later message refers to.

**The thank-you page is not proof of payment.** The shopper reaching `continueURL`
means they finished the redirect, nothing more. Only `PaymentAuthorized` tells you
the money is reserved. A shop that marks orders paid on the return leg will
eventually ship goods nobody paid for.

### How this differs from the Shopify integration

Shopify merchants get order completion for free: SuperPay creates a draft order,
converts it, and writes the authorization transaction through the Shopify Admin
API. A webhook store has no such API, so the same job is split — you create the
order in `PlaceOrder`, and SuperPay tells you when to complete it.

---

## 2. Authentication

Every request SuperPay sends you is signed with a shared secret — the **API key**
you configure (§3).

```
Superpay-API-Token: hex( HMAC-SHA256( raw request body, apiKey ) )
```

Verify it against the **raw** body bytes, before any JSON parsing or
re-serialisation. Re-encoding the body changes the bytes and the signature will
not match.

Reference implementation (`src/tools.ts`):

```ts
createHmacToken(bodyJson, apiKey) // crypto.createHmac('sha256', key).update(data).digest('hex')
```

Other headers on every request:

| Header | Meaning |
|---|---|
| `X-Superpay-Store` | Your store's domain key in SuperPay |
| `X-Tracing` | Always `1` |
| `X-Forwarded-For`, `X-SP-Client-IP` | The shopper's IP, when known |
| `X-SP-Session-ID` | SuperPay session id, when known |

Any custom headers you configure are sent too.

**Timeouts and retries.** SuperPay aborts a webhook after **25 seconds** and does
**not** retry on its own. For `PaymentAuthorized` the payment provider's retry
schedule takes that role — see §6.

---

## 3. Configuration

Set per store in the SuperPay admin dashboard, under *Custom API Integration*:

| Field | Notes |
|---|---|
| **API Key** | The HMAC secret above. Required. |
| **Base URL** | Your host. All endpoint paths below are resolved against it. |
| **Endpoints** | One path per webhook, see §4. |
| **Custom Headers (JSON)** | Optional extra headers, e.g. a gateway token. |
| **Auth endpoints** | Only when SuperPay handles customer login for you. |

TLS certificate validation is skipped only when the store is in test mode.

---

## 4. Endpoint reference

Every endpoint is `POST`, sends and expects `application/json`. **A non-JSON
response is treated as a failure even when the status is 200.**

| Config key | Purpose | Required |
|---|---|---|
| `adjustCart` | Create/update the cart; the workhorse of the checkout | yes |
| `getDeliveryMethods` | Shipping options for a cart | yes |
| `getOrdersByUID` | Previous orders, used to prefill the checkout | yes |
| `paymentDetails` | **PlaceOrder** — create the order, return payment URLs | yes |
| `paymentAuthorized` | **PaymentAuthorized** — payment outcome, see §5–6 | see §5 |
| `continueShopping` | Where "continue shopping" leads | no |
| `userIDVerification` | Age/ID verification result | only if used |
| `authGetAccount`, `authCreateCustomerToken`, `authCreateCustomerAccount` | Customer login through SuperPay | only if used |

**Request and response schemas are served live as OpenAPI:**

```
GET /api/stores/webhooks-openapi-doc
```

That document is generated from the same classes the code uses, so it cannot drift
from the implementation. Use it — do not copy schemas by hand.

### PlaceOrder, in brief

The one endpoint whose semantics matter more than its schema.

Request carries `cartId`, `uid`, `customerToken`, `acceptsMarketing`. You must:

1. Create a real order in an **unpaid** state.
2. Return:

| Field | Meaning |
|---|---|
| `orderId` | Your order id. Every later message uses this. |
| `continueURL` | Where the shopper lands after paying. |
| `callbackURL` | Where a PSP that calls you directly should post. See §5. |
| `orderTotal` | Minor units. SuperPay compares it with the cart and reports a mismatch. |

---

## 5. Which PSPs reach you, and how

**This section is the one that catches people out.** How a payment outcome reaches
your shop depends on the payment provider, and the difference is invisible until
you switch provider.

| Provider | Route to your shop | You need `paymentAuthorized`? |
|---|---|---|
| QuickPay | Gets your `callbackURL` from PlaceOrder and posts there directly | Not used today |
| Pensopay | Same | Not used today |
| **Vipps MobilePay** | Registers webhooks **once per sales unit**, not per payment. Its webhooks reach SuperPay only. | **Yes — required** |
| Adyen | Sends no per-payment callback URL | Yes |

A shop that only ever ran on QuickPay has never needed `paymentAuthorized` — the
PSP was talking to it directly and SuperPay was not in the loop. Switch that shop
to Vipps MobilePay and, without this endpoint, **nothing tells the shop the
shopper paid**. Orders sit unpaid until an expiry job cancels them, while the
shopper has seen a thank-you page.

**Implement `paymentAuthorized` regardless of which provider you start on.** It
costs one endpoint and it is the difference between a provider switch being a
config change and being an outage.

If SuperPay receives a payment outcome for a store with no `paymentAuthorized`
endpoint configured, it reports the gap and acknowledges the webhook — retrying
cannot fix a configuration problem.

---

## 6. The `PaymentAuthorized` contract

```
POST <your paymentAuthorized endpoint>
Superpay-API-Token: <hmac>
Content-Type: application/json

{
  "event": "payment.authorized",
  "orderId": "000000123",
  "paymentId": "000000123",
  "authorizationId": "000000123",
  "psp": "Vipps MobilePay",
  "authorizedAmount": 35870,
  "capturedAmount": 0,
  "refundedAmount": 0,
  "currency": "DKK",
  "cartId": "nVUsJqvc4OvrdA4fPmD6dSXHrJMOrGo7",
  "testMode": false
}
```

| Field | Notes |
|---|---|
| `event` | `payment.authorized` — the authorization stands. `payment.cancelled` — the authorization is gone (cancelled, expired, aborted); do not fulfil. |
| `orderId` | The id **you** returned from PlaceOrder. |
| `authorizationId` | Stable across redeliveries of the same event. Usable as an idempotency key. |
| `authorizedAmount` | **Minor units** (Danish øre). `35870` is 358,70 kr. |
| `testMode` | The payment was made against the provider's test environment. |

### `payment.authorized` means the authorization stands, not that nothing has happened since

It is sent for every state in which the shopper's money is accounted for —
including after a capture, a partial capture, or a refund. Vipps keeps a payment
in `AUTHORIZED` for its whole life, and a refund does not undo the fact that the
order was paid.

So do not read the event alone. `capturedAmount` and `refundedAmount` tell you
what has actually moved:

| What you see | What it means |
|---|---|
| `capturedAmount: 0` | Reserved, not yet drawn |
| `capturedAmount > 0` | Money taken |
| `refundedAmount > 0` | Money given back — the order was still paid, and must not be un-fulfilled on the strength of this event |

`payment.cancelled` is the only event that means release the order. SuperPay
sends it solely for a genuine cancellation, expiry or abort — never for a
refund. Treating "not capturable any more" as "cancelled" is exactly how a paid,
shipped order gets voided.

### Two requirements that are not optional

**1. Be idempotent.** The same event will arrive more than once. Vipps MobilePay
redelivers with backoff for up to **7 days**, and a redelivery after you have
already handled the event must be a no-op that still answers 2xx. Guard on your
own order state — "already paid" is the simplest guard and the right one.

**2. Validate the amount.** Compare `authorizedAmount` against your own order
total before marking the order paid. **SuperPay cannot do this for you** — it does
not hold your order, only the cart it was built from — so this check exists in
exactly one place, and that place is your code.

### Status codes decide retries

The payment provider's retry schedule is the retry mechanism. Your status code
chooses whether to use it.

| You answer | SuperPay does | Use when |
|---|---|---|
| **2xx** | Done. | Handled — including a redelivery you ignored. |
| **4xx** | Reports it once and acknowledges the provider. **No retry.** | Permanent: you do not know this order, the amount is wrong. |
| **5xx** or timeout | Fails the provider callback, so the provider redelivers. | Temporary: your database is down, a lock is held. |

**Do not answer 4xx for a temporary problem** — you will never hear about that
payment again. **Do not answer 5xx for a permanent one** — you will be retried for
a week, generating an alert every time.

### Error codes

Return one of these as `error_code`, `error`, `message` or `code` in a JSON body
and SuperPay maps it to a typed exception instead of a generic failure:

`CART_NOT_FOUND` · `PHONE_INVALID` · `AUTH_INVALID_CREDENTIALS` ·
`AUTH_INVALID_CUSTOMER_TOKEN` · `AUTH_ACCOUNT_NOT_FOUND` ·
`AUTH_ACCOUNT_ALREADY_EXISTS` · `AUTH_PASSWORD_WEAK`

---

## 7. Testing your implementation

SuperPay ships a reference implementation of the shop side — a fake merchant
serving every endpoint in this guide, used by SuperPay's own end-to-end tests:

- `src/test-store/test-store-webhook.controller.ts` — all endpoints, including
  `payment-authorized` with the idempotency and amount checks from §6
- `src/test-store/test-store-payment-authorized.e2e-spec.ts` — the relay driven
  end to end over real HTTP

Read the controller when a detail here is ambiguous; it is executable documentation.

---

## Appendix: implementing this in Magento (Efiware)

Specific to `supervin-ecom`, where `PlaceOrder` is already implemented and only the
payment outcome is missing.

**What exists today.** `Efiware\Supervin\Controller\Superpay\PaymentDetails`
creates the order, parks it in `pending_payment`, and returns
`callback_url = quickpaygateway/payment/callback`. On QuickPay that works: QuickPay
posts there and `Efiware\Quickpay\Controller\Payment\Callback::execute()` moves the
order to `processing`. On Vipps MobilePay nothing posts there, and the order stays
in `pending_payment` — the failure this guide exists to prevent.

**What to add.**

1. **A controller** at `POST /efiware/superpay/PaymentAuthorized`, extending
   `Efiware\Supervin\Controller\Superpay\SuperpayPostController`. That base class
   already verifies the `Superpay-Api-Token` HMAC and disables CSRF, exactly as
   `PaymentDetails` does — so §2 needs no new code.

2. **Reuse the logic that already marks an order paid.**
   `Efiware\Quickpay\Controller\Payment\Callback::execute()` contains the whole
   sequence: the idempotency guards (already `processing`, `getLastTransId()` set),
   `STATE_PROCESSING`, `createTransaction(TYPE_AUTH)`, the autocapture invoice, and
   `sendConfirmationIfMissing()`. Extract it into a service both controllers call
   rather than duplicating it — two copies of payment-completion logic will drift,
   and the drift will be discovered in production.

   That controller currently has **no tests** (your own
   `integration-test-coverage-plan.md` lists it as an untested priority). Put a
   characterization test on it *before* the extraction, so the refactor is
   provably behaviour-preserving.

3. **Order lookup** is by increment id — the value `PaymentDetails` returns as
   `orderId`.

4. **Register the endpoint** in SuperPay's admin dashboard under
   *Custom API Integration → Payment Authorized Endpoint*. The field is optional,
   so leaving it empty fails silently rather than loudly.

**Testing.** The harness exists:
`Efiware\MagentoSimulation\Tools\SuperpayControllerTestService` builds
`POST /efiware/superpay/<Action>` requests and signs them with the shared key
(`genericPostControllerRequest()`, `addHmacHeader()`). `PaymentDetailsTest.php` is
the model to copy: place an order, assert `pending_payment`, post the payment
outcome, assert `processing` with an authorization transaction.
