Coastal Pay API

Coastal Pay API

Accept crypto payments with a single API call. Send us a payment request and we return a unique wallet address and QR code. We monitor the blockchain, confirm the payment, and notify you via webhook.

How it works
1. You create a payment via our API
2. We return a wallet address + QR code to show your customer
3. Customer sends crypto to the address
4. We detect and confirm the payment on-chain
5. We POST a webhook to your server with the result

Authentication

All API requests require a Bearer token in the Authorization header. You can generate API keys from your dashboard.

Authorization: Bearer cpay_live_xxxxxxxxxxxxx
Keep your keys safe
Never expose your API key in client-side code. All API calls should be made from your backend server.

Endpoints

POST /api/v1/payments

Creates a new crypto payment and returns a unique wallet address + QR code.

Request Body

merchant_id Required Your merchant or store identifier (pass-through, returned in webhooks and reports)
transaction_id Required Your unique transaction/order ID (used for idempotency)
amount_usd Required Payment amount in USD
currency Required Crypto currency for payment
customer_id Optional Your customer identifier
callback_url Optional URL for webhook notifications

Supported Currencies

BTC ETH SOL USDC_ETH USDC_SOL USDT_ETH USDT_SOL ETH_BASE USDC_BASE

Example Request

{
  "merchant_id": "merch_abc123",
  "customer_id": "cust_789",
  "transaction_id": "txn_456",
  "amount_usd": 50.00,
  "currency": "BTC",
  "callback_url": "https://your-server.com/webhooks/crypto"
}
201 Created
{
  "payment_id": "cpay_a1b2c3d4e5f6g7h8i9j0",
  "status": "awaiting_payment",
  "wallet_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
  "crypto_amount": "0.000587230000000000",
  "currency": "BTC",
  "exchange_rate": "85150.23",
  "qr_code": "data:image/png;base64,iVBOR...",
  "qr_page_url": "https://api-pay.coastalai.dev/api/v1/payments/cpay_a1b2c3d4e5f6g7h8i9j0/qr",
  "qr_image_url": "https://api-pay.coastalai.dev/api/v1/payments/cpay_a1b2c3d4e5f6g7h8i9j0/qr?raw=1",
  "expires_at": "2026-03-12T15:30:00.000Z",
  "amount_usd": "50.00"
}

GET /api/v1/payments/:id

Returns full payment details including current status, timestamps, and wallet information.

200 OK
{
  "payment_id": "cpay_a1b2c3d4e5f6g7h8i9j0",
  "status": "confirmed",
  "merchant_id": "merch_abc123",
  "transaction_id": "txn_456",
  "amount_usd": "50.00",
  "currency": "BTC",
  "crypto_amount": "0.000587230000000000",
  "exchange_rate": "85150.23",
  "wallet_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
  "failure_code": null,
  "expires_at": "2026-03-12T15:30:00.000Z",
  "confirmed_at": "2026-03-12T15:12:34.000Z",
  "created_at": "2026-03-12T15:00:00.000Z",
  "tx_hash": "3aTyjZpmaauZz81MU7a8MHdJyrNwFZeoBYUTHY1yiqMLk9b4oeRhz6jNWxTfP2UqoB7mF3vRG5cdE8nAHJ4QsXp",
  "received_amount": "0.385012000000000000",
  "confirmed_at": "2026-03-12T15:12:34.000Z"
}

Note: received_amount is the actual crypto amount received on-chain. For underpayments, status will be "failed" with failure_code: "UNDERPAYMENT". Compare received_amount vs crypto_amount to see the shortfall.

GET /api/v1/payments/:id/status

Lightweight endpoint for polling payment status. Returns only essential status fields.

200 OK
{
  "payment_id": "cpay_a1b2c3d4e5f6g7h8i9j0",
  "status": "confirmed",
  "failure_code": null,
  "confirmed_at": "2026-03-12T15:12:34.000Z",
  "tx_hash": "3aTyjZpmaauZz81MU7a8MHdJyrNwFZeoBYUTHY1yiqMLk9b4oeRhz6jNWxTfP2UqoB7mF3vRG5cdE8nAHJ4QsXp",
  "received_amount": "0.385012000000000000",
  "confirmed_at": "2026-03-12T15:12:34.000Z"
}

Note: received_amount is the actual crypto amount received on-chain. For underpayments, status will be "failed" with failure_code: "UNDERPAYMENT". Compare received_amount vs crypto_amount to see the shortfall.

GET /api/v1/payments/:id/qr

Returns a branded, browser-viewable payment page with QR code, wallet address, amount, and live status. No authentication required — safe to share directly with customers or embed in iframes.

Query Parameters:

  • raw=1 — Returns the QR code as a raw PNG image (Content-Type: image/png) instead of the full HTML page. Useful for embedding in emails or custom UIs.

Default: Full branded HTML payment page
With ?raw=1: Raw PNG image of the QR code

200 OK — HTML page or PNG image

GET /health

Health check endpoint. No authentication required.

200 OK
{
  "status": "ok",
  "timestamp": "2026-03-12T15:00:00.000Z"
}

Payment Statuses

Every payment moves through a lifecycle. Here are the possible statuses:

Status Description
awaiting_payment Payment created, waiting for customer to send crypto
confirmed Payment received and confirmed on blockchain
expired Payment window expired (30 minutes), no payment received
refunded Payment was refunded (underpayment, error, etc.)
failed Payment failed — see failure_code for reason

Failure Codes

When a payment has status failed, the failure_code field indicates the reason:

Code Description
UNDERPAYMENT Customer sent less than required amount (>2% below)
OVERPAYMENT Customer sent more than required amount
PAYMENT_AFTER_EXPIRY Payment received after the 30-minute window
NETWORK_ERROR Blockchain network issue

Payment Webhooks

When a payment status changes, we send a POST request to your callback_url with the payment details:

{
  "event": "payment",
  "payment_id": "cpay_a1b2c3d4e5f6g7h8i9j0",
  "status": "confirmed",
  "merchant_id": "merch_abc123",
  "transaction_id": "txn_456",
  "amount_usd": "50.00",
  "currency": "SOL",
  "crypto_amount": "0.385012000000000000",
  "chain": "solana",
  "wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
  "timestamp": "2026-03-12T15:12:34.000Z",
  "tx_hash": "3aTyjZpmaauZz81MU7a8MHdJyrNwFZeoBYUTHY1yiqMLk9b4oeRhz6jNWxTfP2UqoB7mF3vRG5cdE8nAHJ4QsXp",
  "received_amount": "0.385012000000000000",
  "confirmed_at": "2026-03-12T15:12:34.000Z"
}

Note: received_amount is the actual crypto amount received on-chain. For underpayments, status will be "failed" with failure_code: "UNDERPAYMENT". Compare received_amount vs crypto_amount to see the shortfall.

Verifying Webhook Signatures

Every callback includes an X-Coastal-Signature header containing an HMAC-SHA256 signature of the request body. Verify it against your shared secret to confirm the webhook is authentic and hasn't been tampered with.

Retry Policy

If your server doesn't return a 2xx status code, we retry with exponential backoff:

Immediate 30s 2min 10min 1hr 4hr 24hr

7 attempts total before the webhook is marked as failed.

Payout Webhooks

When merchant payouts are executed from a CSV batch, we send a POST request to your payout webhook URL for each individual merchant payment as it confirms or fails on-chain. This is a separate URL from your payment webhooks — configure it in the Merchant Portal.

payout — confirmed

Sent when a merchant payout transaction is confirmed on-chain:

{
  "event": "payout",
  "batch_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "merchant_id": "MERCH_001",
  "payment_id": "PAY_20260319_001",
  "amount_usdc": "94.15",
  "status": "confirmed",
  "tx_hash": "0x7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b",
  "chain": "base",
  "merchant_wallet": "0x1234567890abcdef1234567890abcdef12345678",
  "timestamp": "2026-03-19T14:30:00.000Z"
}

payout — failed

Sent when a merchant payout transaction fails:

{
  "event": "payout",
  "batch_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "merchant_id": "MERCH_001",
  "payment_id": "PAY_20260319_002",
  "amount_usdc": "150.00",
  "status": "failed",
  "chain": "base",
  "merchant_wallet": "0xabcdef1234567890abcdef1234567890abcdef12",
  "error": "Insufficient USDC balance",
  "timestamp": "2026-03-19T14:30:05.000Z"
}

Payload Fields

event string Always payout — check status for the outcome
batch_id string UUID of the CSV payout batch this row belongs to
merchant_id string Your internal merchant identifier (from the CSV upload)
payment_id string Your internal payment identifier (from the CSV) — used for cross-batch deduplication
amount_usdc string USDC amount sent to the merchant
status string confirmed or failed
tx_hash string Blockchain transaction hash (confirmed events only)
chain string Blockchain network — base, solana, or ethereum
merchant_wallet string Destination wallet address the USDC was sent to
error string Error message (failed events only)
timestamp string ISO 8601 timestamp of the event

Headers

X-Coastal-Signature string HMAC-SHA256 signature of the request body — verify against your API secret
X-Coastal-Event string Always payout — check status for the outcome
X-Coastal-Batch-Id string UUID of the payout batch
X-Coastal-Attempt string Delivery attempt number (1-7)

Retry Policy

Same retry policy as payment webhooks:

Immediate 30s 2min 10min 1hr 4hr 24hr

7 attempts total. 4xx responses are treated as successful delivery.

Configuration

Configure your payout webhook URL in the Merchant Portal → Webhooks → Merchant Payouts tab. This is a separate URL from payment webhooks, allowing you to route payout notifications to a different endpoint on your server.

Code Examples

Complete, working examples for creating payments, checking status, and verifying webhook signatures.

const axios = require('axios');
const crypto = require('crypto');

const API_KEY = 'cpay_live_xxxxxxxxxxxxx';
const BASE_URL = 'https://api-pay.coastalai.dev';
const WEBHOOK_SECRET = 'your_webhook_secret';

const client = axios.create({
  baseURL: BASE_URL,
  headers: { 'Authorization': `Bearer ${API_KEY}` }
});

// --- Create a payment ---
async function createPayment() {
  const { data } = await client.post('/api/v1/payments', {
    merchant_id: 'merch_abc123',
    customer_id: 'cust_789',
    transaction_id: 'txn_456',
    amount_usd: 50.00,
    currency: 'BTC',
    callback_url: 'https://your-server.com/webhooks/crypto'
  });

  console.log('Payment created:', data.payment_id);
  console.log('Send', data.crypto_amount, 'BTC to:', data.wallet_address);
  return data;
}

// --- Check payment status ---
async function checkStatus(paymentId) {
  const { data } = await client.get(`/api/v1/payments/${paymentId}/status`);
  console.log('Status:', data.status);
  return data;
}

// --- Verify webhook signature ---
function verifyWebhook(rawBody, signature) {
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// Express webhook handler example
app.post('/webhooks/crypto', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-coastal-signature'];

  if (!verifyWebhook(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body);
  console.log('Payment', event.payment_id, 'is now', event.status);

  res.status(200).json({ received: true });
});
import requests
import hmac
import hashlib

API_KEY = "cpay_live_xxxxxxxxxxxxx"
BASE_URL = "https://api-pay.coastalai.dev"
WEBHOOK_SECRET = "your_webhook_secret"

headers = {"Authorization": f"Bearer {API_KEY}"}

# --- Create a payment ---
def create_payment():
    payload = {
        "merchant_id": "merch_abc123",
        "customer_id": "cust_789",
        "transaction_id": "txn_456",
        "amount_usd": 50.00,
        "currency": "BTC",
        "callback_url": "https://your-server.com/webhooks/crypto"
    }

    response = requests.post(
        f"{BASE_URL}/api/v1/payments",
        json=payload,
        headers=headers
    )
    response.raise_for_status()
    data = response.json()

    print(f"Payment created: {data['payment_id']}")
    print(f"Send {data['crypto_amount']} BTC to: {data['wallet_address']}")
    return data

# --- Check payment status ---
def check_status(payment_id):
    response = requests.get(
        f"{BASE_URL}/api/v1/payments/{payment_id}/status",
        headers=headers
    )
    response.raise_for_status()
    data = response.json()

    print(f"Status: {data['status']}")
    return data

# --- Verify webhook signature ---
def verify_webhook(raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# Flask webhook handler example
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/webhooks/crypto", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Coastal-Signature", "")

    if not verify_webhook(request.data, signature):
        return jsonify({"error": "Invalid signature"}), 401

    event = request.get_json()
    print(f"Payment {event['payment_id']} is now {event['status']}")

    return jsonify({"received": True}), 200
<?php

$apiKey       = 'cpay_live_xxxxxxxxxxxxx';
$baseUrl      = 'https://api-pay.coastalai.dev';
$webhookSecret = 'your_webhook_secret';

// --- Create a payment ---
function createPayment() {
    global $apiKey, $baseUrl;

    $payload = json_encode([
        'merchant_id'    => 'merch_abc123',
        'customer_id'    => 'cust_789',
        'transaction_id' => 'txn_456',
        'amount_usd'     => 50.00,
        'currency'       => 'BTC',
        'callback_url'   => 'https://your-server.com/webhooks/crypto',
    ]);

    $ch = curl_init("$baseUrl/api/v1/payments");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $apiKey",
            'Content-Type: application/json',
        ],
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 201) {
        throw new \Exception("API error: $response");
    }

    $data = json_decode($response, true);
    echo "Payment created: " . $data['payment_id'] . "\n";
    echo "Send " . $data['crypto_amount'] . " BTC to: " . $data['wallet_address'] . "\n";

    return $data;
}

// --- Check payment status ---
function checkStatus($paymentId) {
    global $apiKey, $baseUrl;

    $ch = curl_init("$baseUrl/api/v1/payments/$paymentId/status");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $apiKey",
        ],
    ]);

    $response = curl_exec($ch);
    curl_close($ch);

    $data = json_decode($response, true);
    echo "Status: " . $data['status'] . "\n";

    return $data;
}

// --- Verify webhook signature ---
function verifyWebhook($rawBody, $signature) {
    global $webhookSecret;

    $expected = hash_hmac('sha256', $rawBody, $webhookSecret);
    return hash_equals($expected, $signature);
}

// Webhook handler
$rawBody   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_COASTAL_SIGNATURE'] ?? '';

if (!verifyWebhook($rawBody, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$event = json_decode($rawBody, true);
echo "Payment " . $event['payment_id'] . " is now " . $event['status'] . "\n";

http_response_code(200);
echo json_encode(['received' => true]);
?>

Rate Limits

The API is rate-limited to 100 requests per minute per API key.

Each response includes rate limit headers:

X-RateLimit-Limit Maximum requests per window (100)
X-RateLimit-Remaining Requests remaining in current window

When the limit is exceeded, the API returns a 429 Too Many Requests response. Wait for the window to reset before retrying.

Errors

All errors return a consistent JSON structure:

{
  "error": "error_code",
  "message": "Human readable description"
}
Status Error Code Description
400 validation_error Invalid request body
401 unauthorized Missing or invalid API key
404 not_found Payment not found
429 rate_limit_exceeded Too many requests
503 price_unavailable Price feed temporarily unavailable

Idempotency

If you send the same transaction_id twice, the API returns the original payment instead of creating a duplicate. The response will include "idempotent": true to indicate this.

Safe retries
This means you can safely retry failed requests without worrying about creating duplicate payments. Just use the same transaction_id.