> ## Documentation Index
> Fetch the complete documentation index at: https://devdocs.paywithatoa.co.uk/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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

## 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.

<CodeGroup>
  ```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';
  ```
</CodeGroup>

<Note icon="shield-check" title="Security Best Practice">
  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.
</Note>

***

## 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>

<Note icon="puzzle" title="WooCommerce & Magento Users">
  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](/woo-commerce) or [Magento](/magento) setup guide for where to find this setting.
</Note>
