> ## Documentation Index
> Fetch the complete documentation index at: https://docs.paymove.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> ## paymove integration rules for AI agents
>
> These rules are verified against the live API. Follow them exactly.
>
> 1. Amounts are INTEGERS in grosze (minor units). `1000` means 10.00 PLN. PLN is the only supported currency and there is no currency field in the API.
> 2. A float `price` (e.g. `12.99`) is SILENTLY TRUNCATED to 12 grosze and still returns HTTP 200. Always send an integer: `Math.round(pln * 100)`.
> 3. Authenticate with the `X-API-KEY` header. Never `Authorization: Bearer`. Sandbox keys start with `sk_test_`, production keys with `sk_live_`.
> 4. Server-side only. A call from a merchant page is rejected. Never put the key in frontend code.
> 5. Unknown request fields are silently ignored and still return HTTP 200 — a wrong body shape looks like success. Match the documented shape exactly.
> 6. A missing `externalId` returns HTTP 500 `Something went wrong`, not 400. A missing `price` or `details.returnUrl` returns HTTP 200 and a usable `redirectUrl` — no error at all. Validate the body yourself before sending it.
> 7. Always verify the `X-Paymove-Signature` header on incoming webhooks before trusting them: https://docs.paymove.io/en/webhook-signature.md
> 8. Errors are `{"status": <int>, "message": "<text>"}`. Branch on the HTTP status only — never on `message`, which is unstable and leaks internal class names.
> 9. There is no rate limiting, no HTTP 429, no HTTP 422, no `Idempotency-Key` header and no API versioning. Do not write code that handles them.
> 10. Re-POSTing an `externalId` that already exists returns HTTP 200 with the ORIGINAL `redirectUrl` and silently discards EVERY field you send — the new price, description and details are all ignored. Use `PATCH /api/pay/product/{productId}/subproduct/{externalId}` to change a price.
> 11. Webhooks fire only on `COMPLETED` by default, and `retries` defaults to `0` (no retries) unless you set it explicitly. Delivery counts as successful when the HTTP status equals `expectedCode` — the response body is never inspected.
> 12. Never fulfil an order on the `returnUrl` redirect. The checkout does not redirect there by itself: the customer has to click "back to shop", and inside the widget modal that redirect never happens. Fulfil only in the webhook handler, after verifying the signature.
> 13. Do not pin a version of `@paymove-io/sdk` — install the latest.
> 14. Full documentation index: https://docs.paymove.io/llms.txt · Copy-paste quickstart: https://docs.paymove.io/en/quickstart.md · Agent skill: https://docs.paymove.io/skill.md

# Webhook signature verification

> How to check the X-Paymove-Signature header — the HMAC-SHA256 algorithm plus ready-to-copy code for Node.js, Python and Java.

Paymove signs every outgoing webhook request with HMAC-SHA256. This lets you confirm that a notification genuinely came from Paymove, and not from someone who learned your endpoint URL.

<Warning>
  Signature verification is mandatory. Without it, anyone who knows your `endpoint` can send a forged payment notification and have an order fulfilled without paying.
</Warning>

## The header

Every request carries a single signature header:

```
X-Paymove-Signature: t=1708790400,v1=K4oSHnBOYVPuGCRnKv8bDLACmwbHBZPxPC+r24/lqHk=
```

| Component | Description                                                                                                                    |
| --------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `t=`      | Unix epoch seconds when the message was signed                                                                                 |
| `v1=`     | HMAC-SHA256 signature, base64-encoded. The `v1` prefix allows a future algorithm change without breaking existing integrations |

## Signing secret

Each webhook has its own secret in the format `whsec_<base64>`. You receive it in the response to [registering the webhook](/en/webhooks#2-webhook-registration), in the `signingSecret` field.

<Warning>
  Use the **entire string including the `whsec_` prefix**, UTF-8 encoded, as the HMAC key. Do not strip the prefix and do not base64-decode the remainder - this is the single most common cause of failing verification.
</Warning>

If you lose the secret, you can read it again from the `GET /api/pay/plugin/webhook/{webhookId}` response - there is no need to rotate it for that reason.

## Algorithm

1. Read the `X-Paymove-Signature` header.
2. Split the value on the comma into the `t=<timestamp>` and `v1=<signature>` parts.
3. Build the signed content: `{timestamp}.{raw_request_body}`.
4. Compute HMAC-SHA256 using the full secret (with prefix) as the key.
5. Base64-encode the result and prepend `v1=`.
6. Compare it with the signature from the header using a timing-safe comparison.

<Warning>
  The signature is computed over the **raw request body**, byte for byte. If your framework parses the JSON and re-serializes it, the result will almost certainly differ. Capture the raw body before parsing - in Express via `express.raw({ type: "application/json" })`, in Next.js via `await request.text()`.
</Warning>

## Code

### Node.js

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
const crypto = require("crypto");

function verifyWebhook(secret, signatureHeader, rawBody) {
  if (!signatureHeader) return false;

  const [tPart, signature] = signatureHeader.split(",", 2);
  if (!tPart?.startsWith("t=") || !signature?.startsWith("v1=")) return false;

  const timestamp = tPart.slice(2);

  // 5-minute tolerance window - protects against replaying an old request
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;

  const hash = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("base64");
  const expected = `v1=${hash}`;

  // timingSafeEqual throws on differing lengths - check the length first
  if (expected.length !== signature.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

Usage in Express:

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
const express = require("express");
const app = express();

app.post(
  "/api/payments/webhook",
  express.raw({ type: "application/json" }), // raw body, unparsed
  (req, res) => {
    const rawBody = req.body.toString("utf8");

    if (!verifyWebhook(process.env.PAYMOVE_WEBHOOK_SECRET, req.get("X-Paymove-Signature"), rawBody)) {
      return res.status(401).json({ error: "invalid signature" });
    }

    const event = JSON.parse(rawBody);

    // Respond immediately - Paymove applies no timeout,
    // and a slow endpoint blocks processing on the gateway side.
    res.status(200).json({ status: "ok" });

    // Do the fulfilment asynchronously and idempotently
    fulfillOrder(event.orderId ?? event.externalId).catch(console.error);
  }
);
```

### Python

```python theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
import hmac
import hashlib
import base64
import time


def verify_webhook(secret: str, signature_header: str, raw_body: str) -> bool:
    if not signature_header:
        return False

    parts = signature_header.split(",", 1)
    if len(parts) != 2 or not parts[0].startswith("t=") or not parts[1].startswith("v1="):
        return False

    timestamp, signature = parts[0][2:], parts[1]

    # 5-minute tolerance window
    try:
        if abs(int(time.time()) - int(timestamp)) > 300:
            return False
    except ValueError:
        return False

    digest = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.{raw_body}".encode("utf-8"),
        hashlib.sha256,
    ).digest()
    expected = "v1=" + base64.b64encode(digest).decode("utf-8")

    return hmac.compare_digest(expected, signature)
```

### Java

```java theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;

public boolean verifyWebhook(String secret, String signatureHeader, String rawBody) throws Exception {
    if (signatureHeader == null) return false;

    String[] parts = signatureHeader.split(",", 2);
    if (parts.length != 2 || !parts[0].startsWith("t=") || !parts[1].startsWith("v1=")) return false;

    String timestamp = parts[0].substring(2);
    String signature = parts[1];

    // 5-minute tolerance window
    long age = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp));
    if (age > 300) return false;

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] hash = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
    String expected = "v1=" + Base64.getEncoder().encodeToString(hash);

    return MessageDigest.isEqual(
        expected.getBytes(StandardCharsets.UTF_8),
        signature.getBytes(StandardCharsets.UTF_8)
    );
}
```

## Test vector

Check your implementation against the values below - skip the tolerance window, since the timestamp is in the past:

| Item            | Value                                                          |
| --------------- | -------------------------------------------------------------- |
| Secret          | `whsec_dGVzdHNlY3JldHRlc3RzZWNyZXR0ZXN0c2VjcmV0cw==`           |
| Body            | `{"event":"payment.completed"}`                                |
| Timestamp       | `1708790400`                                                   |
| Signed content  | `1708790400.{"event":"payment.completed"}`                     |
| Expected header | `t=1708790400,v1=1rODbpFCfITTJax9V5WnIWRfrxR0cvvg3yXGVGhlV7s=` |

If your function returns a different signature, check in order: that you use the full secret including the `whsec_` prefix, that you encode to base64 (not hex), and that you sign the raw body without re-serializing it.

## Replay protection

The timestamp is covered by the signature, so it cannot be tampered with - but Paymove itself does not reject old requests. Your server decides how long a signature stays valid. The recommended window is **5 minutes**; the examples above already apply it.

On top of that, fulfil orders idempotently, keyed on `externalId`. A redelivered notification must never result in shipping the goods twice.

## Rotating the secret

```bash theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
curl --request POST \
  --url https://gateway-api.sandbox.paymove.io/api/pay/plugin/webhook/6b23ecd9-14c8-47fc-add0-b71ec50e9d66/rotate-secret \
  --header 'X-API-KEY: sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
```

<Warning>
  The previous secret stops working **immediately** - there is no period during which both are accepted. Update your own configuration at the same moment, otherwise you will start rejecting genuine notifications.
</Warning>

## What's next

<CardGroup cols={2}>
  <Card title="Payment statuses" icon="list-check" href="/en/payment-status">
    What the webhook payload contains and how to check a payment's state.
  </Card>

  <Card title="Webhook configuration" icon="webhook" href="/en/webhooks">
    Registering a webhook and assigning it to a product.
  </Card>
</CardGroup>
