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 identifier
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 USDC_ETH USDC_SOL USDT_ETH USDT_SOL

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...",
  "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"
}

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"
}

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

Webhooks (Callbacks)

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

{
  "payment_id": "cpay_a1b2c3d4e5f6g7h8i9j0",
  "status": "confirmed",
  "merchant_id": "merch_abc123",
  "transaction_id": "txn_456",
  "amount_usd": "50.00",
  "currency": "BTC",
  "crypto_amount": "0.000587230000000000",
  "wallet_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
  "confirmed_at": "2026-03-12T15:12:34.000Z"
}

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.

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.