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

# Authentication

> Ed25519 request signing for all GoPay APIs

# Authentication

Every GoPay API request — payments (C2B) and transfers (B2C) alike — is authenticated with a detached **Ed25519 signature** sent in HTTP headers. Your private key stays on your servers; GoPay stores only the public verification key. This means a compromise of GoPay's database can never be used to forge requests on your behalf.

## API Keys

Create API keys in the merchant dashboard. Each key has:

* A **public key identifier** — sent with every request in the `X-Public-Key` header
* An **Ed25519 key pair** — you sign with the private half, GoPay verifies with the public half
* **Permissions** — `c2b` (accept payments), `b2c` (make transfers), or both
* Optional **IP allowlist**, per-merchant **rate limit**, and **expiry**

Two ways to get a key pair:

<Tabs>
  <Tab title="Server-generated (default)">
    GoPay generates the key pair and returns the private key **once**, at creation time. Store it in a secrets manager immediately — GoPay does not keep a copy and cannot recover it.
  </Tab>

  <Tab title="Bring your own key">
    Generate an Ed25519 key pair yourself and register only the **public** key. GoPay never sees any private material at all. Recommended if you have key-management infrastructure.
  </Tab>
</Tabs>

<Warning>
  If a private key is lost or leaked, rotate it from the dashboard. Rotation takes effect within seconds.
</Warning>

## Required Headers

| Header            | Description                                                                                                                                  |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Public-Key`    | Your API key identifier                                                                                                                      |
| `X-Signature`     | `ed25519=<base64>` — detached signature over the canonical string. Unpadded base64 preferred; padded and bare (no prefix) forms are accepted |
| `X-Timestamp`     | Unix seconds, base-10. Rejected if more than 1 minute in the future or older than the freshness window (default 5 minutes)                   |
| `X-Request-ID`    | Unique nonce per request. Each ID is accepted exactly once — resending one is rejected as a replay                                           |
| `Idempotency-Key` | Optional, C2B only. Recommended when you retry on network errors                                                                             |
| `Content-Type`    | `application/json`                                                                                                                           |

## The Canonical String (v1)

The signature is computed over this string — lines joined with `\n`, **no trailing newline**:

```
v1
{METHOD}
{host}
{path}
{sorted_query}
{timestamp}
{request_id}
{body_sha256_hex}
```

| Line              | Rule                                                                                                    |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| `v1`              | Literal version prefix                                                                                  |
| `METHOD`          | Uppercase HTTP method (`POST`)                                                                          |
| `host`            | Lowercase host, including port if non-default                                                           |
| `path`            | Request path, e.g. `/api/v1/initiate-payment`                                                           |
| `sorted_query`    | Query parameters sorted by key, then value, re-encoded (`k=v&k2=v2`); empty string if there is no query |
| `timestamp`       | Same value as `X-Timestamp`                                                                             |
| `request_id`      | Same value as `X-Request-ID`                                                                            |
| `body_sha256_hex` | Lowercase hex SHA-256 of the exact body bytes you send                                                  |

Sign the canonical string with your Ed25519 private key and base64-encode the 64-byte result.

<Note>
  Because the **host** is part of the canonical string, a signature created for the sandbox cannot be replayed against production (and vice versa). Always sign with the exact host you send the request to.
</Note>

## Signing Helpers

Your private key is the base64 string shown once at key creation — it decodes to a 64-byte key (some libraries want only the first 32 bytes, the seed). These helpers return the exact headers to attach to any GoPay API request.

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

    // 64-byte Ed25519 private key, base64 (shown once at key creation)
    const PRIVATE_KEY = Buffer.from(process.env.GOPAY_PRIVATE_KEY, 'base64');

    function canonicalizeQuery(rawQuery) {
        if (!rawQuery) return '';
        const pairs = [...new URLSearchParams(rawQuery)];
        pairs.sort(([k1, v1], [k2, v2]) =>
            k1 === k2 ? (v1 < v2 ? -1 : v1 > v2 ? 1 : 0) : (k1 < k2 ? -1 : 1));
        return pairs
            .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
            .join('&');
    }

    // Returns the headers to attach to the request.
    function signRequest(method, url, bodyBytes) {
        const u = new URL(url);
        const timestamp = Math.floor(Date.now() / 1000).toString();
        const requestId = crypto.randomUUID();
        const bodyHash = crypto.createHash('sha256').update(bodyBytes).digest('hex');

        const canonical = [
            'v1',
            method.toUpperCase(),
            u.host.toLowerCase(),
            u.pathname,
            canonicalizeQuery(u.search.slice(1)),
            timestamp,
            requestId,
            bodyHash,
        ].join('\n');

        const sig = nacl.sign.detached(Buffer.from(canonical, 'utf8'), PRIVATE_KEY);
        return {
            'X-Public-Key': process.env.GOPAY_PUBLIC_KEY,
            'X-Signature': 'ed25519=' + Buffer.from(sig).toString('base64').replace(/=+$/, ''),
            'X-Timestamp': timestamp,
            'X-Request-ID': requestId,
            'Content-Type': 'application/json',
        };
    }

    module.exports = { signRequest };
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    # pip install pynacl
    import base64
    import hashlib
    import os
    import time
    import uuid
    from urllib.parse import urlsplit, parse_qsl, quote

    from nacl.signing import SigningKey

    # 64-byte Ed25519 private key, base64 (shown once at key creation).
    # PyNaCl takes the 32-byte seed, which is the first half.
    _raw = base64.b64decode(os.environ["GOPAY_PRIVATE_KEY"])
    SIGNING_KEY = SigningKey(_raw[:32])


    def canonicalize_query(raw: str) -> str:
        if not raw:
            return ""
        pairs = sorted(parse_qsl(raw, keep_blank_values=True))
        return "&".join(f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in pairs)


    def sign_request(method: str, url: str, body: bytes) -> dict:
        """Returns the auth headers to attach to the request."""
        u = urlsplit(url)
        timestamp = str(int(time.time()))
        request_id = str(uuid.uuid4())
        body_hash = hashlib.sha256(body).hexdigest()

        canonical = "\n".join([
            "v1",
            method.upper(),
            u.netloc.lower(),
            u.path,
            canonicalize_query(u.query),
            timestamp,
            request_id,
            body_hash,
        ])

        sig = SIGNING_KEY.sign(canonical.encode("utf-8")).signature
        return {
            "X-Public-Key": os.environ["GOPAY_PUBLIC_KEY"],
            "X-Signature": "ed25519=" + base64.b64encode(sig).decode().rstrip("="),
            "X-Timestamp": timestamp,
            "X-Request-ID": request_id,
            "Content-Type": "application/json",
        }
    ```
  </Tab>

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

    import (
        "crypto/ed25519"
        "crypto/sha256"
        "encoding/base64"
        "encoding/hex"
        "fmt"
        "net/url"
        "sort"
        "strings"
        "time"

        "github.com/google/uuid"
    )

    // SignRequest returns the auth headers for a GoPay API request.
    // privateKeyB64 is the base64 64-byte Ed25519 private key shown once
    // at key creation; publicKeyID is the X-Public-Key identifier.
    func SignRequest(privateKeyB64, publicKeyID, method, rawURL string, body []byte) (map[string]string, error) {
        keyBytes, err := base64.StdEncoding.DecodeString(privateKeyB64)
        if err != nil {
            return nil, fmt.Errorf("decode private key: %w", err)
        }
        priv := ed25519.PrivateKey(keyBytes)

        u, err := url.Parse(rawURL)
        if err != nil {
            return nil, fmt.Errorf("parse url: %w", err)
        }

        timestamp := fmt.Sprintf("%d", time.Now().Unix())
        requestID := uuid.NewString()
        sum := sha256.Sum256(body)

        canonical := strings.Join([]string{
            "v1",
            strings.ToUpper(method),
            strings.ToLower(u.Host),
            u.Path,
            canonicalizeQuery(u.RawQuery),
            timestamp,
            requestID,
            hex.EncodeToString(sum[:]),
        }, "\n")

        sig := ed25519.Sign(priv, []byte(canonical))
        return map[string]string{
            "X-Public-Key": publicKeyID,
            "X-Signature":  "ed25519=" + base64.RawStdEncoding.EncodeToString(sig),
            "X-Timestamp":  timestamp,
            "X-Request-ID": requestID,
            "Content-Type": "application/json",
        }, nil
    }

    func canonicalizeQuery(raw string) string {
        if raw == "" {
            return ""
        }
        v, err := url.ParseQuery(raw)
        if err != nil {
            return raw
        }
        keys := make([]string, 0, len(v))
        for k := range v {
            keys = append(keys, k)
        }
        sort.Strings(keys)
        var b strings.Builder
        for i, k := range keys {
            vals := append([]string(nil), v[k]...)
            sort.Strings(vals)
            for j, val := range vals {
                if i > 0 || j > 0 {
                    b.WriteByte('&')
                }
                b.WriteString(url.QueryEscape(k))
                b.WriteByte('=')
                b.WriteString(url.QueryEscape(val))
            }
        }
        return b.String()
    }
    ```
  </Tab>
</Tabs>

## What Else Is Checked

Beyond the signature, GoPay enforces per key:

1. **Permission** — the key must carry `c2b` for payments or `b2c` for transfers
2. **IP allowlist** — if configured, requests from other IPs are rejected
3. **Replay protection** — each `X-Request-ID` is accepted exactly once
4. **Rate limit** — a per-merchant request budget

Failed authentication returns a deliberately generic error — the response never reveals which check failed. See [Errors](/getting-started/errors) for the full taxonomy.

## Common Mistakes

<Warning>
  * **Re-serializing the JSON body after signing** — hash, sign, and send the **same bytes**. Serialize once.
  * **Signing with the wrong host** — sandbox and production signatures are not interchangeable; the host is part of the canonical string.
  * **Reusing an `X-Request-ID`** — each one is accepted exactly once; generate a fresh UUID per request (including retries).
  * **Timestamps in milliseconds** — the API expects **unix seconds**.
  * **Signing with the wrong key half** — some libraries want the 64-byte private key, others the 32-byte seed (its first half).
</Warning>

## Verifying Webhooks

The same scheme runs in reverse for callbacks: GoPay signs every webhook with its **gateway private key**, and you verify with the gateway **public key** you receive at onboarding. See [Webhooks](/webhooks/index#signature-verification) for verification code.
