coal
coal

Security Best Practices

This page covers the security practices you should follow when integrating Coal into your application. Following these guidelines protects your merchant API key, your console session, your users' funds, and your business from common attack patterns.


Never Expose Merchant API Keys Client-Side

Your Coal merchant API key (coal_live_*) grants access to the public merchant API: creating checkout sessions, managing webhooks, and reading merchant data. It must never appear in browser-executed code.

Console actions use a Privy Bearer token instead. Do not reuse your merchant API key for /api/console/* routes.

Wrong — key exposed in a Next.js client component:

typescript
1// ❌ This sends your API key to every visitor's browser
2'use client';
3const res = await fetch('https://api.usecoal.xyz/api/checkouts', {
4 headers: { 'x-api-key': 'coal_live_sk_xxxxxxxxxxxxxxxxxxxx' },
5});

Correct — key only used in a server-side context:

typescript
1// ✅ app/api/checkout/route.ts (Next.js Route Handler — runs on the server)
2import { NextResponse } from 'next/server';
3
4export async function POST(req: Request) {
5 const { slug } = await req.json();
6
7 const res = await fetch('https://api.usecoal.xyz/api/checkouts', {
8 method: 'POST',
9 headers: {
10 'Content-Type': 'application/json',
11 'x-api-key': process.env.COAL_API_KEY!, // ✅ public merchant API key only in server env
12 },
13 body: JSON.stringify({ amount: 49.99, productName: slug }),
14 });
15
16 const data = await res.json();
17 // Return only what the browser needs
18 return NextResponse.json({ checkoutId: data.id });
19}

Use Environment Variables

Store secrets in environment variables, never in source code or version-controlled config files.

bash
1# .env.local (never commit this file)
2COAL_API_KEY=coal_live_sk_xxxxxxxxxxxxxxxxxxxx
3COAL_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Add .env.local to your .gitignore:

text
1# .gitignore
2.env
3.env.local
4.env.production

For CI/CD pipelines and production deployments, inject secrets as environment variables in your hosting provider's dashboard (Vercel, Railway, AWS Secrets Manager, etc.) — not via committed files.


Verify Webhook Signatures

Every webhook request Coal sends includes an X-Coal-Signature header — an HMAC-SHA256 signature of the raw request body, signed with your webhook secret. Verify this signature on every webhook request.

Without signature verification, an attacker could send fake checkout.session.completed events to your endpoint and trigger order fulfillment for payments that never happened.

typescript
1// app/api/webhooks/coal/route.ts
2import { createHmac, timingSafeEqual } from 'crypto';
3import { NextRequest, NextResponse } from 'next/server';
4
5const WEBHOOK_SECRET = process.env.COAL_WEBHOOK_SECRET!;
6
7function verifySignature(payload: string, signature: string): boolean {
8 const expected = createHmac('sha256', WEBHOOK_SECRET)
9 .update(payload, 'utf8')
10 .digest('hex');
11
12 // Use timingSafeEqual to prevent timing attacks
13 return timingSafeEqual(
14 Buffer.from(signature, 'hex'),
15 Buffer.from(expected, 'hex')
16 );
17}
18
19export async function POST(req: NextRequest) {
20 const rawBody = await req.text();
21 const signature = req.headers.get('x-coal-signature') ?? '';
22
23 if (!verifySignature(rawBody, signature)) {
24 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
25 }
26
27 const event = JSON.parse(rawBody);
28
29 switch (event.event) {
30 case 'checkout.session.completed':
31 await fulfillOrder(event.data.id);
32 break;
33 // handle other event types...
34 }
35
36 return NextResponse.json({ received: true });
37}

Always read the raw request body before parsing as JSON. Many frameworks parse the body before your handler runs, which changes the bytes and breaks signature verification.


Idempotency for Webhook Handlers

Coal may deliver the same webhook event more than once (due to network retries, timeouts, etc.). Your handler must be idempotent — processing the same event twice should not double-fulfill an order.

Use the id field in the webhook payload as an idempotency key:

typescript
1async function fulfillOrder(sessionId: string) {
2 // Check if this session has already been fulfilled
3 const existing = await db.order.findUnique({ where: { sessionId } });
4 if (existing?.fulfilledAt) {
5 console.log(`Order ${sessionId} already fulfilled — skipping`);
6 return;
7 }
8
9 // Mark as fulfilled atomically before doing side effects
10 await db.order.update({
11 where: { sessionId },
12 data: { fulfilledAt: new Date(), status: 'fulfilled' },
13 });
14
15 // Now perform the fulfillment (send email, grant access, etc.)
16 await sendConfirmationEmail(sessionId);
17 await grantUserAccess(sessionId);
18}

HTTPS Required in Production

Coal's webhook delivery system will only deliver to https:// endpoints in production. HTTP callback URLs are rejected.

For local development, use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local server over HTTPS:

bash
1# Expose localhost:3000 over HTTPS for webhook testing
2ngrok http 3000
3# → Forwarding: https://abc123.ngrok.io → http://localhost:3000

Then use https://abc123.ngrok.io/api/webhooks/coal as your callback URL when testing.


Rate Limiting

Coal enforces rate limits on all API endpoints to protect against abuse. See the full Rate Limits reference for the complete table.

Summary of limits:

Endpoint categoryLimit
Authentication5 req / min
Checkout/session init10 req / min
Payment confirmation10 req / min
Console (Privy Bearer)60 req / min
Public verify/status30 req / min

When you exceed a rate limit, the API returns 429 Too Many Requests with a Retry-After header indicating when you can retry.


Principle of Least Privilege

Create separate API keys for separate contexts. Do not use your primary key everywhere.

Recommended key strategy:

  • Production backend key — used only in your production server. Full permissions.
  • Staging/test key — used in test environments only. Can be rotated freely without affecting production.
  • CI/CD key (if needed) — minimal permissions for automated testing only.

Revoke any key immediately if it is accidentally committed to a public repository or logs.


Rotate Keys Regularly

Even without a known compromise, rotate your API keys on a regular schedule:

  1. Go to Console → API Keys → Create New Key
  2. Update your environment variables with the new key
  3. Deploy the new key to production
  4. Verify your integration is working
  5. Revoke the old key from Console → API Keys

Aim to rotate keys at least every 90 days, or immediately after:

  • An engineer with access leaves the organization
  • A key appears in logs, error messages, or version control
  • A third-party service you shared the key with is compromised

Webhook Endpoint Security

In addition to signature verification, protect your webhook endpoint:

  • Return 200 OK quickly (within 5 seconds) and process asynchronously to avoid timeouts
  • Do not expose detailed error messages (return generic 400 / 401, not stack traces)
  • Optionally, IP-allowlist Coal's outbound IP ranges (available in your Console settings)
  • Log all received webhook payloads for audit purposes, but scrub sensitive fields