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

# Payment Webhooks

> Real-time payment notifications for your business

# Payment Callback Integration Guide

When a transaction reaches a final state, GoPay sends a signed HTTP POST to the `notifyUrl` / `notify_url` you supplied when initiating it. Callbacks reflect the **already-settled** state of a transaction — they are the authoritative result, unlike browser redirects which can be lost or forged.

## Callback Types

<CardGroup>
  <Card title="C2B Callbacks" icon="arrow-right" href="/webhooks/c2b-callbacks">
    Notifications for payments made by customers to your business
  </Card>

  <Card title="B2C Callbacks" icon="arrow-left" href="/webhooks/b2c-callbacks">
    Notifications for payouts/disbursements made from your business to customers
  </Card>
</CardGroup>

## Webhook Requirements

Your webhook endpoint must:

* **Use HTTPS** — plain HTTP notify URLs are rejected at initiation time and callbacks are never sent over HTTP
* **Be publicly reachable** — GoPay refuses to deliver to private, loopback, or link-local addresses
* **Accept HTTP POST requests** and **return a 2xx status code** for successful receipt
* **Respond within 10 seconds** — acknowledge first, process asynchronously
* **Not rely on redirects** — GoPay does not follow 3xx responses; a redirect counts as a failed delivery

## Security Headers

Every callback is signed with GoPay's **gateway Ed25519 private key**. You verify it with the corresponding **gateway public key**, which you receive during onboarding. There is no shared secret: the signature proves the callback came from GoPay, and nothing you store can be used to forge one.

| Header               | Description                                                                                                                      |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `X-Signature`        | `ed25519=<base64>` — detached signature over the canonical string (unpadded base64)                                              |
| `X-Signature-Key-ID` | Identifier of the gateway signing key. Lets GoPay rotate keys without downtime — keep verifying against the key matching this ID |
| `X-Timestamp`        | Unix seconds when the callback was signed                                                                                        |
| `X-Request-ID`       | Unique delivery ID (e.g. `cb_9f2c...`) — use it for replay protection and dedup                                                  |
| `X-Merchant-ID`      | Your merchant ID                                                                                                                 |
| `Content-Type`       | `application/json`                                                                                                               |

## Signature Verification

The signature covers the **v1 canonical string** (lines joined with `\n`, no trailing newline):

```
v1
POST
{host}              # host of YOUR notify URL, lowercase, incl. port if non-default
{path}              # path of your notify URL
{sorted_query}      # canonicalized query of your notify URL; empty if none
{timestamp}         # value of X-Timestamp
{request_id}        # value of X-Request-ID
{body_sha256_hex}   # lowercase hex sha256 of the raw request body bytes
```

Verification steps:

1. Read the **raw request body bytes** (before any JSON parsing) and compute the lowercase hex SHA-256
2. Rebuild the canonical string using your registered notify URL's host, path, and query — if you are behind a proxy or load balancer, use the URL you registered, not the incoming `Host` header
3. Strip the `ed25519=` prefix from `X-Signature` and base64-decode the 64-byte signature
4. Verify with the GoPay gateway public key
5. Reject stale timestamps (e.g. older than 5 minutes) and `X-Request-ID` values you have already seen

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    // npm install tweetnacl
    const express = require('express');
    const crypto = require('crypto');
    const nacl = require('tweetnacl');

    // GoPay gateway PUBLIC key (base64, 32 bytes) — from onboarding
    const GATEWAY_PUBLIC_KEY = Buffer.from(process.env.GOPAY_GATEWAY_PUBLIC_KEY, 'base64');

    // Your registered notify URL — must match what you sent at initiation
    const NOTIFY_URL = new URL('https://your-domain.com/gopay-webhook');

    const app = express();
    // Capture the RAW body — the hash must cover the exact bytes received
    app.use(express.json({
        verify: (req, _res, buf) => { req.rawBody = buf; },
    }));

    function verifyCallback(req) {
        const sig = (req.headers['x-signature'] || '').replace(/^ed25519=/, '');
        const timestamp = req.headers['x-timestamp'];
        const requestId = req.headers['x-request-id'];
        if (!sig || !timestamp || !requestId) return false;

        // Freshness window (5 minutes)
        if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

        const bodyHash = crypto.createHash('sha256').update(req.rawBody).digest('hex');
        const canonical = [
            'v1',
            'POST',
            NOTIFY_URL.host.toLowerCase(),
            NOTIFY_URL.pathname,
            '', // no query on the notify URL in this example
            timestamp,
            requestId,
            bodyHash,
        ].join('\n');

        return nacl.sign.detached.verify(
            Buffer.from(canonical, 'utf8'),
            Buffer.from(sig, 'base64'),
            GATEWAY_PUBLIC_KEY,
        );
    }

    app.post('/gopay-webhook', (req, res) => {
        if (!verifyCallback(req)) {
            return res.status(401).send('Invalid signature');
        }
        // TODO: dedupe on req.headers['x-request-id'], then process async
        console.log('Valid callback:', req.body);
        res.status(200).send('OK');
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    # pip install pynacl flask
    import base64
    import hashlib
    import os
    import time

    from flask import Flask, request, abort
    from nacl.exceptions import BadSignatureError
    from nacl.signing import VerifyKey

    # GoPay gateway PUBLIC key (base64, 32 bytes) — from onboarding
    VERIFY_KEY = VerifyKey(base64.b64decode(os.environ["GOPAY_GATEWAY_PUBLIC_KEY"]))

    # Your registered notify URL host + path
    NOTIFY_HOST = "your-domain.com"
    NOTIFY_PATH = "/gopay-webhook"

    app = Flask(__name__)


    def verify_callback(req) -> bool:
        sig = req.headers.get("X-Signature", "").removeprefix("ed25519=")
        timestamp = req.headers.get("X-Timestamp", "")
        request_id = req.headers.get("X-Request-ID", "")
        if not sig or not timestamp or not request_id:
            return False

        # Freshness window (5 minutes)
        if abs(time.time() - int(timestamp)) > 300:
            return False

        body_hash = hashlib.sha256(req.get_data()).hexdigest()
        canonical = "\n".join([
            "v1",
            "POST",
            NOTIFY_HOST,
            NOTIFY_PATH,
            "",  # no query on the notify URL in this example
            timestamp,
            request_id,
            body_hash,
        ])

        # Accept unpadded base64
        padded = sig + "=" * (-len(sig) % 4)
        try:
            VERIFY_KEY.verify(canonical.encode(), base64.b64decode(padded))
            return True
        except (BadSignatureError, ValueError):
            return False


    @app.route("/gopay-webhook", methods=["POST"])
    def webhook():
        if not verify_callback(request):
            abort(401)
        # TODO: dedupe on X-Request-ID, then process async
        data = request.get_json()
        print("Valid callback:", data)
        return "OK", 200
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    package main

    import (
        "crypto/ed25519"
        "crypto/sha256"
        "encoding/base64"
        "encoding/hex"
        "io"
        "net/http"
        "os"
        "strconv"
        "strings"
        "time"
    )

    // GoPay gateway PUBLIC key (base64, 32 bytes) — from onboarding
    var gatewayPublicKey ed25519.PublicKey

    // Your registered notify URL host + path
    const (
        notifyHost = "your-domain.com"
        notifyPath = "/gopay-webhook"
    )

    func init() {
        raw, err := base64.StdEncoding.DecodeString(os.Getenv("GOPAY_GATEWAY_PUBLIC_KEY"))
        if err != nil || len(raw) != ed25519.PublicKeySize {
            panic("invalid GOPAY_GATEWAY_PUBLIC_KEY")
        }
        gatewayPublicKey = ed25519.PublicKey(raw)
    }

    func verifyCallback(r *http.Request, body []byte) bool {
        sig := strings.TrimPrefix(r.Header.Get("X-Signature"), "ed25519=")
        timestamp := r.Header.Get("X-Timestamp")
        requestID := r.Header.Get("X-Request-ID")
        if sig == "" || timestamp == "" || requestID == "" {
            return false
        }

        // Freshness window (5 minutes)
        secs, err := strconv.ParseInt(timestamp, 10, 64)
        if err != nil || time.Since(time.Unix(secs, 0)).Abs() > 5*time.Minute {
            return false
        }

        sum := sha256.Sum256(body)
        canonical := strings.Join([]string{
            "v1",
            "POST",
            notifyHost,
            notifyPath,
            "", // no query on the notify URL in this example
            timestamp,
            requestID,
            hex.EncodeToString(sum[:]),
        }, "\n")

        sigBytes, err := base64.RawStdEncoding.DecodeString(sig)
        if err != nil {
            sigBytes, err = base64.StdEncoding.DecodeString(sig)
            if err != nil {
                return false
            }
        }
        return ed25519.Verify(gatewayPublicKey, []byte(canonical), sigBytes)
    }

    func webhookHandler(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
        if err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        if !verifyCallback(r, body) {
            http.Error(w, "invalid signature", http.StatusUnauthorized)
            return
        }
        // TODO: dedupe on X-Request-ID, then process async
        w.WriteHeader(http.StatusOK)
    }
    ```
  </Tab>
</Tabs>

## Retry Policy

* Failed deliveries are retried up to **5 times** with increasing delays: **5s, 30s, 2m, 10m, 30m**
* Only HTTP 2xx responses count as successful; timeouts, errors, and redirects are all failures
* After all retries are exhausted, the callback is parked for manual review — contact support to redrive it

<Warning>
  Make your webhook handler idempotent. Retries mean you can receive the same callback more than once — dedupe on `X-Request-ID` (per delivery) or on `txnId` + `status` (per settlement event).
</Warning>

## Status Values

| Status      | Description                           |
| ----------- | ------------------------------------- |
| `completed` | Transaction settled successfully      |
| `failed`    | Transaction failed                    |
| `expired`   | Transaction expired before completion |
| `cancelled` | Transaction was cancelled             |

All statuses delivered by webhook are **final** — a transaction never moves out of one of these states.

## Key Rotation

GoPay periodically rotates the gateway signing key. During a rotation window both keys are active:

1. `X-Signature-Key-ID` tells you which key signed each callback
2. Keep a small map of key ID to public key, and verify against the matching one
3. New public keys are announced ahead of time through the merchant dashboard

## Testing Your Webhook

* **ngrok** — for local development tunneling (remember: the tunnel URL must be HTTPS)
* **curl** — for wiring checks before signatures are involved

```bash theme={null}
curl -X POST https://your-domain.com/gopay-webhook \
  -H "Content-Type: application/json" \
  -H "X-Signature: ed25519=test" \
  -H "X-Signature-Key-ID: gw-2026-01" \
  -H "X-Timestamp: 1770000000" \
  -H "X-Request-ID: cb_0123456789abcdef0123456789abcdef" \
  -H "X-Merchant-ID: your-merchant-id" \
  -d '{"txnId":"TEST123","status":"completed","amount":100.50,"txnType":"c2b","currency":"ETB"}'
```

A correct implementation rejects this request with 401 (the signature is invalid) — that is the expected result. To exercise the success path end to end, use the sandbox environment described in the [Testing Guide](/resources/testing-guide).
