Skip to main content

Overview

The PayAI facilitator authenticates merchants using short-lived JWTs signed with Ed25519 (the EdDSA algorithm). If you use the @payai/facilitator TypeScript package, this is handled automatically. This guide explains the underlying protocol so you can implement authentication in any language without depending on PayAI packages.
Authentication is only required beyond the free tier (1,000 settlements/month). Create a merchant account at merchant.payai.network to get your API keys.

API key structure

Your API key has two parts, available from the merchant dashboard:
PartEnvironment VariableDescription
API Key IDPAYAI_API_KEY_IDA string identifier for your key
API Key SecretPAYAI_API_KEY_SECRETAn Ed25519 private key in PKCS#8/DER format, base64-encoded
The secret may be prefixed with payai_sk_ as shown in the dashboard. Strip this prefix before use — the remaining string is a standard base64-encoded PKCS#8 DER key.

Protocol steps

1. Normalize the API key secret

If the secret starts with payai_sk_, remove that prefix. The result is a base64-encoded Ed25519 private key in PKCS#8/DER format.

2. Build the JWT header

{
  "alg": "EdDSA",
  "typ": "JWT",
  "kid": "<your_api_key_id>"
}

3. Build the JWT payload

{
  "sub": "<your_api_key_id>",
  "iss": "payai-merchant",
  "iat": 1709700000,
  "exp": 1709700120,
  "jti": "550e8400-e29b-41d4-a716-446655440000"
}
ClaimDescription
subYour API Key ID
issAlways "payai-merchant"
iatCurrent time as a Unix timestamp (seconds)
expExpiration — iat + 120 (2 minutes recommended)
jtiA random UUID v4 for replay protection

4. Encode and sign

  1. Base64url-encode the header JSON -> headerB64
  2. Base64url-encode the payload JSON -> payloadB64
  3. Form the signing input: headerB64 + "." + payloadB64
  4. Sign the UTF-8 bytes of the signing input with your Ed25519 private key
  5. Base64url-encode the 64-byte signature -> signatureB64
  6. The JWT is: headerB64.payloadB64.signatureB64
Base64url encoding uses the standard Base64 alphabet with + replaced by -, / replaced by _, and no = padding.

5. Send the request

Include the JWT as a Bearer token on all facilitator requests:
Authorization: Bearer <jwt>
This header is required on all authenticated facilitator endpoints: POST /verify, POST /settle, and GET /supported.

Token caching

JWTs are valid for the full exp - iat window (default: 120 seconds). To avoid signing on every request, cache the token and refresh it ~30 seconds before expiry.

Code examples

The following examples implement the full authentication flow with no PayAI-specific dependencies.
Uses the Web Crypto API — works in Node.js 18+, Deno, Bun, and browsers with zero dependencies.
// payai-auth.ts — zero dependencies, Web Crypto API only

function base64UrlEncode(data: Uint8Array): string {
  let binary = "";
  for (let i = 0; i < data.byteLength; i++) {
    binary += String.fromCharCode(data[i]);
  }
  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

function base64UrlEncodeString(str: string): string {
  return base64UrlEncode(new TextEncoder().encode(str));
}

function base64ToUint8Array(base64: string): Uint8Array {
  const standardized = base64.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(standardized);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

function normalizeSecret(secret: string): string {
  const trimmed = secret.trim();
  return trimmed.startsWith("payai_sk_")
    ? trimmed.slice("payai_sk_".length)
    : trimmed;
}

export async function generatePayAIJwt(
  apiKeyId: string,
  apiKeySecret: string,
): Promise<string> {
  const now = Math.floor(Date.now() / 1000);

  const header = JSON.stringify({
    alg: "EdDSA",
    typ: "JWT",
    kid: apiKeyId,
  });
  const payload = JSON.stringify({
    sub: apiKeyId,
    iss: "payai-merchant",
    iat: now,
    exp: now + 120,
    jti: crypto.randomUUID(),
  });

  const headerB64 = base64UrlEncodeString(header);
  const payloadB64 = base64UrlEncodeString(payload);
  const message = `${headerB64}.${payloadB64}`;

  const keyBytes = base64ToUint8Array(normalizeSecret(apiKeySecret));
  const privateKey = await crypto.subtle.importKey(
    "pkcs8",
    keyBytes.buffer,
    { name: "Ed25519" },
    false,
    ["sign"],
  );

  const signature = await crypto.subtle.sign(
    "Ed25519",
    privateKey,
    new TextEncoder().encode(message),
  );

  return `${message}.${base64UrlEncode(new Uint8Array(signature))}`;
}

// --- Usage ---

const jwt = await generatePayAIJwt(
  "your-api-key-id",
  "payai_sk_your-api-key-secret",
);

const response = await fetch(
  "https://facilitator.payai.network/verify",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify({
      x402Version: 2,
      paymentPayload: {
        /* ... */
      },
      paymentRequirements: {
        /* ... */
      },
    }),
  },
);

Facilitator endpoints

All endpoints are at https://facilitator.payai.network and require the Authorization: Bearer <jwt> header when authenticated.
EndpointMethodDescription
/verifyPOSTVerify a payment payload against payment requirements
/settlePOSTSettle a verified payment on-chain
/supportedGETList supported networks, tokens, and schemes

Request body (/verify and /settle)

{
  "x402Version": 2,
  "paymentPayload": { "..." },
  "paymentRequirements": { "..." }
}
For full payload schemas, see the x402 Reference.

Troubleshooting

ErrorCauseFix
401 UnauthorizedMissing or expired JWTGenerate a fresh token (check exp claim)
Failed to parse keySecret not valid PKCS#8/DEREnsure you stripped the payai_sk_ prefix and the remaining string is valid base64
Signature verification failedWrong key or corrupted secretVerify your API Key ID and Secret match the merchant dashboard

Need help?

Join our Community

Have questions or want to connect with other developers? Join our Discord server.