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

# V2 Webhook Signing

> How to verify Atoa v2 webhook signatures so you can trust incoming events, with the signing scheme and example code.

V2 signing verifies webhook authenticity with a single HMAC-SHA256 signature covering the entire request body, replacing the per-field `signatureHash` used in V1.

***

## How v2 signature verification works

Generating a signing secret switches all webhook deliveries to V2. Here is what changes:

| | V1 (Legacy) | V2 (Signing Secret) |
| - | - | - |
| **Signature location** | `signatureHash` field inside the JSON body | `X-Atoa-Signature` HTTP header |
| **What is signed** | `orderId \| paymentRequestId` | The entire JSON request body |
| **Algorithm** | HMAC-SHA256 | HMAC-SHA256 |
| **Extra body fields** | `signatureHash`, `signature` present | `eventType` added; `signatureHash` and `signature` removed |

When Atoa delivers a webhook to a V2-enabled endpoint:

1. The `signatureHash` and `signature` fields are removed from the body.
2. An `eventType` field is added to the body (e.g. `"PAYMENTS_STATUS"`, `"POS_PAYMENT_STATUS"`).
3. The full JSON body is signed: `HMAC-SHA256(signingSecret, requestBody)`.
4. The signature is sent in the `X-Atoa-Signature` header as `v1=<hex-encoded hash>`.

***

## Getting started

**1. Generate a signing secret**

Go to the [Atoa Dashboard](https://dashboard.paywithatoa.co.uk/my-account/settings/webhooks) and navigate to **Settings → Webhooks**. Click **Generate a signing key**. You will be asked to confirm your password.

Once generated, the secret is displayed **once** in the format:

```
whsec_<base64-encoded value>
```

Copy and store it securely. You will not be able to view it again.

**2. Store the secret securely**

Store the signing secret in an environment variable or a secrets manager. Never hard-code it in your source code or commit it to version control.

**3. Verify signatures on your server**

For every incoming webhook request, compute the expected signature and compare it to the value in the `X-Atoa-Signature` header. See the verification examples below.

***

## Verifying the signature

To verify an incoming webhook:

1. **Extract the signature** — Read the `X-Atoa-Signature` header value and strip the `v1=` prefix to get the received hex signature.
2. **Decode your signing secret** — The secret is in the format `whsec_<base64-encoded key>`. Strip the `whsec_` prefix and base64-decode the remaining value to get the raw signing key bytes.
3. **Compute the expected signature** — Calculate `HMAC-SHA256(key = decoded secret bytes, data = raw request body)` and hex-encode the result.
4. **Compare signatures** — Use a timing-safe comparison to check the received signature against the expected signature. If they match, the webhook is authentic.

```javascript Node.js theme={null}
const crypto = require('crypto');

function verifyWebhookSignature(signingSecret, rawBody, signatureHeader) {
  // signatureHeader is the value of X-Atoa-Signature, e.g. "v1=abc123..."
  const receivedSig = signatureHeader.replace('v1=', '');

  // Strip whsec_ prefix and base64-decode to get raw signing key
  const secret = Buffer.from(signingSecret.split('_')[1], 'base64');

  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(receivedSig, 'hex'),
    Buffer.from(expectedSig, 'hex')
  );
}

// Express.js example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-atoa-signature'];
  const rawBody = req.body.toString('utf8');

  if (!verifyWebhookSignature(process.env.ATOA_SIGNING_SECRET, rawBody, signature)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(rawBody);
  // Process the event...
  res.status(200).send('OK');
});
```

```python Python theme={null}
import hmac
import hashlib
import base64

def verify_webhook_signature(signing_secret, raw_body, signature_header):
    """Verify the X-Atoa-Signature header."""
    received_sig = signature_header.replace("v1=", "")

    # Strip whsec_ prefix and base64-decode to get raw signing key
    secret = base64.b64decode(signing_secret.split("_", 1)[1])

    expected_sig = hmac.new(
        secret,
        raw_body.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(received_sig, expected_sig)

# Flask example
@app.route("/webhook", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Atoa-Signature", "")
    raw_body = request.get_data(as_text=True)

    if not verify_webhook_signature(SIGNING_SECRET, raw_body, signature):
        return "Invalid signature", 401

    event = request.get_json()
    # Process the event...
    return "OK", 200
```

```java Java theme={null}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Base64;

public class WebhookVerifier {

    private static final String HMAC_SHA256 = "HmacSHA256";

    public static boolean verifySignature(
            String signingSecret, String rawBody, String signatureHeader) {

        String receivedSig = signatureHeader.replace("v1=", "");

        try {
            // Strip whsec_ prefix and base64-decode to get raw signing key
            byte[] secret = Base64.getDecoder().decode(
                signingSecret.split("_", 2)[1]);

            SecretKeySpec keySpec = new SecretKeySpec(secret, HMAC_SHA256);
            Mac mac = Mac.getInstance(HMAC_SHA256);
            mac.init(keySpec);

            byte[] hash = mac.doFinal(rawBody.getBytes("UTF-8"));
            StringBuilder hex = new StringBuilder();
            for (byte b : hash) {
                hex.append(String.format("%02x", b));
            }

            return MessageDigest.isEqual(
                receivedSig.getBytes(), hex.toString().getBytes());
        } catch (Exception e) {
            return false;
        }
    }
}
```

```go Go theme={null}
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"io"
	"net/http"
	"strings"
)

func verifySignature(signingSecret, rawBody, signatureHeader string) bool {
	receivedSig := strings.TrimPrefix(signatureHeader, "v1=")

	// Strip whsec_ prefix and base64-decode to get raw signing key
	parts := strings.SplitN(signingSecret, "_", 2)
	secret, _ := base64.StdEncoding.DecodeString(parts[1])

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(rawBody))
	expectedSig := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(receivedSig), []byte(expectedSig))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	signature := r.Header.Get("X-Atoa-Signature")
	body, _ := io.ReadAll(r.Body)

	if !verifySignature(signingSecret, string(body), signature) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	// Process the event...
	w.WriteHeader(http.StatusOK)
}
```

```php PHP theme={null}
<?php

function verifyWebhookSignature(
    string $signingSecret, string $rawBody, string $signatureHeader
): bool {
    $receivedSig = str_replace('v1=', '', $signatureHeader);

    // Strip whsec_ prefix and base64-decode to get raw signing key
    $secret = base64_decode(explode('_', $signingSecret, 2)[1]);
    $expectedSig = hash_hmac('sha256', $rawBody, $secret);

    return hash_equals($receivedSig, $expectedSig);
}

// Usage
$signature = $_SERVER['HTTP_X_ATOA_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');

if (!verifyWebhookSignature($signingSecret, $rawBody, $signature)) {
    http_response_code(401);
    echo 'Invalid signature';
    exit;
}

$event = json_decode($rawBody, true);
// Process the event...
http_response_code(200);
echo 'OK';
```

> [!NOTE]
>
> Always use a timing-safe comparison function (such as `crypto.timingSafeEqual`
> in Node.js or `hmac.compare_digest` in Python) when comparing signatures.
> This prevents timing attacks that could allow an attacker to guess the
> signature byte by byte.

***

## Key rotation

You can rotate your signing secret at any time from the Atoa Dashboard under **Settings → Webhooks**. Rotation requires password confirmation.

When you rotate:

* A new secret is generated and displayed once — copy it immediately.
* The previous secret is **immediately invalidated**. All subsequent deliveries use the new secret.
* Update the secret in your server environment before confirming the rotation to avoid failed verifications.

***

## Backward compatibility

V2 signing is opt-in per merchant. Without a signing secret, webhooks continue in V1 format with `signatureHash` and `signature` fields. Generating a secret switches your deliveries to V2 without affecting other merchants.

> [!NOTE]
>
> Subscribing to the `POS_PAYMENT_STATUS` event type requires a V2 signing
> secret. You must generate a signing secret before you can create a
> `POS_PAYMENT_STATUS` webhook subscription.

> [!NOTE]
>
> Generating a signing secret switches **all** webhook deliveries to V2 — including those sent to your WooCommerce or Magento plugin. To keep plugin webhooks working:
>
> 1. Update the Atoa plugin to the latest version.
> 2. Paste your signing secret into the plugin's webhook signing key field and save.
>
> See the [WooCommerce](https://docs.paywithatoa.com/woo-commerce.md) or [Magento](https://docs.paywithatoa.com/magento.md) setup guide for where to find this setting.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.