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:
1// ❌ This sends your API key to every visitor's browser2'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:
1// ✅ app/api/checkout/route.ts (Next.js Route Handler — runs on the server)2import { NextResponse } from 'next/server';34export async function POST(req: Request) {5 const { slug } = await req.json();67 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 env12 },13 body: JSON.stringify({ amount: 49.99, productName: slug }),14 });1516 const data = await res.json();17 // Return only what the browser needs18 return NextResponse.json({ checkoutId: data.id });19}
Use Environment Variables
Store secrets in environment variables, never in source code or version-controlled config files.
1# .env.local (never commit this file)2COAL_API_KEY=coal_live_sk_xxxxxxxxxxxxxxxxxxxx3COAL_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Add .env.local to your .gitignore:
1# .gitignore2.env3.env.local4.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.
1// app/api/webhooks/coal/route.ts2import { createHmac, timingSafeEqual } from 'crypto';3import { NextRequest, NextResponse } from 'next/server';45const WEBHOOK_SECRET = process.env.COAL_WEBHOOK_SECRET!;67function verifySignature(payload: string, signature: string): boolean {8 const expected = createHmac('sha256', WEBHOOK_SECRET)9 .update(payload, 'utf8')10 .digest('hex');1112 // Use timingSafeEqual to prevent timing attacks13 return timingSafeEqual(14 Buffer.from(signature, 'hex'),15 Buffer.from(expected, 'hex')16 );17}1819export async function POST(req: NextRequest) {20 const rawBody = await req.text();21 const signature = req.headers.get('x-coal-signature') ?? '';2223 if (!verifySignature(rawBody, signature)) {24 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });25 }2627 const event = JSON.parse(rawBody);2829 switch (event.event) {30 case 'checkout.session.completed':31 await fulfillOrder(event.data.id);32 break;33 // handle other event types...34 }3536 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:
1async function fulfillOrder(sessionId: string) {2 // Check if this session has already been fulfilled3 const existing = await db.order.findUnique({ where: { sessionId } });4 if (existing?.fulfilledAt) {5 console.log(`Order ${sessionId} already fulfilled — skipping`);6 return;7 }89 // Mark as fulfilled atomically before doing side effects10 await db.order.update({11 where: { sessionId },12 data: { fulfilledAt: new Date(), status: 'fulfilled' },13 });1415 // 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:
1# Expose localhost:3000 over HTTPS for webhook testing2ngrok http 30003# → 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 category | Limit |
|---|---|
| Authentication | 5 req / min |
| Checkout/session init | 10 req / min |
| Payment confirmation | 10 req / min |
| Console (Privy Bearer) | 60 req / min |
| Public verify/status | 30 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:
- Go to Console → API Keys → Create New Key
- Update your environment variables with the new key
- Deploy the new key to production
- Verify your integration is working
- 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 OKquickly (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
