Skip to content

Quickstart · test mode

From a test key to a verified webhook.

Create a test key, make a payment request, play the customer who pays it, and check that a signed event reaches your server. Everything runs in the sandbox: no real funds, chain or payment provider is involved.

Before you start

  • A merchant account. Create one and verify your email.
  • Node.js 20 or newer, Python 3.9 or newer, or just curl. The SDKs have no runtime dependencies.
  • A way to reach localhost:3000 over public https, such as cloudflared tunnel --url http://localhost:3000 or ngrok http 3000. Webhook URLs must be public https, so the sandbox cannot call your laptop directly.

1. Create a test key

  1. Open API & apps in the dashboard and choose New API key.
  2. Set Mode to Test and tick payments:read, payments:write and webhooks:manage.
  3. Confirm with your passkey or authenticator code, then copy the secret. It starts with rvk_test_ and is shown once.
Your shell
export RAVELO_API=https://api.ravelopay.app
export RAVELO_TEST_KEY=rvk_test_...

A test key can never act as a live key and a live key can never call the sandbox. Test objects carry "livemode": false.

2. Install the SDK (optional)

The curl tabs need nothing. If you prefer an SDK, it handles retries, idempotency keys and signature checks for you.

Install
npm install @ravelo/sdk    # TypeScript or JavaScript, Node 20+
pip install ravelo         # Python 3.9+

3. Write the webhook receiver

Trust a payment only when a signed event says so; a browser redirect proves nothing. The receiver checks the Ravelo-Signature header against the raw request body, ignores events it has already handled, and answers 200 quickly.

webhook-server.ts

TypeScript

import { createServer } from "node:http";
import { verifyWebhook, WebhookSignatureError } from "@ravelo/sdk";

const secret = process.env.RAVELO_WEBHOOK_SECRET!;
const handled = new Set<string>(); // keep this in your database; deliveries can repeat

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks/ravelo") {
    res.writeHead(404).end();
    return;
  }
  // Verify the bytes exactly as they arrived; parsing and re-serialising the JSON would break the signature.
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  try {
    const event = await verifyWebhook(Buffer.concat(chunks), req.headers["ravelo-signature"] as string | undefined, secret);
    if (!handled.has(event.id)) {
      handled.add(event.id);
      console.log("verified", event.type, event.id);
      if (event.type === "payment_intent.succeeded") console.log("paid:", event.data.id);
    }
    res.writeHead(200).end();
  } catch (err) {
    console.error("rejected:", err instanceof Error ? err.message : err);
    res.writeHead(err instanceof WebhookSignatureError ? 400 : 500).end();
  }
}).listen(3000, () => console.log("listening on http://localhost:3000/webhooks/ravelo"));

Python

import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from ravelo import SIGNATURE_HEADER, WebhookSignatureError, verify_webhook

SECRET = os.environ["RAVELO_WEBHOOK_SECRET"]
handled = set()  # keep this in your database; deliveries can repeat


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/webhooks/ravelo":
            self.send_response(404)
            self.end_headers()
            return
        # Verify the bytes exactly as they arrived; parsing and re-serialising the JSON would break the signature.
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        try:
            event = verify_webhook(raw, self.headers.get(SIGNATURE_HEADER), SECRET)
        except WebhookSignatureError as err:
            print("rejected:", err)
            self.send_response(400)
            self.end_headers()
            return
        if event["id"] not in handled:
            handled.add(event["id"])
            print("verified", event["type"], event["id"])
            if event["type"] == "payment_intent.succeeded":
                print("paid:", event["data"]["id"])
        self.send_response(200)
        self.end_headers()


print("listening on http://localhost:3000/webhooks/ravelo")
HTTPServer(("", 3000), Handler).serve_forever()

It needs the endpoint secret, which you get in the next step. Do not start it yet.

4. Register the endpoint

Start your tunnel, then register its address. An empty event_types subscribes to everything. The response carries the signing secret whsec_…, shown once, and the endpoint id.

curl

curl -sS "$RAVELO_API/v1/webhook-endpoints" \
  -H "Authorization: Bearer $RAVELO_TEST_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://YOUR-TUNNEL.example/webhooks/ravelo",
    "description": "Quickstart"
  }'
Response (trimmed)
{
  "id": "0192f1e0-4c2a-7f8e-9a1b-3c4d5e6f7a8b",
  "url": "https://YOUR-TUNNEL.example/webhooks/ravelo",
  "event_types": [],
  "status": "active",
  "secret": "whsec_..."
}

Because you registered it with the test key, the endpoint belongs to the sandbox and receives the events of test payments. Endpoints you add for live traffic, for example on the Webhooks page of the dashboard, are separate and have their own secret.

Start the receiver
export RAVELO_WEBHOOK_SECRET=whsec_...
export ENDPOINT_ID=<the id above>

npx tsx webhook-server.ts      # TypeScript
python webhook_server.py       # Python

5. Send a test event

This checks the whole path, tunnel and signature included, before any payment is involved. The API answers 202 with the queued event.

curl

curl -sS -X POST "$RAVELO_API/v1/webhook-endpoints/$ENDPOINT_ID/test" \
  -H "Authorization: Bearer $RAVELO_TEST_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

Your receiver prints verified webhook.test and the event id within a few seconds. If it prints rejected or nothing, see Troubleshooting before going on.

6. Create a payment and simulate funding

A payment request fixes an asset, an amount and a deadline and returns the address the customer pays. In test mode the address is synthetic. simulate-funding plays the customer: it sends what is still due to that address and the payment goes through the same intake, verification and crediting pipeline as a real deposit.

Amounts are integer strings in base units: 25000000 is 25 USDT, which has 6 decimals. The payer also covers our fee, so the simulated transfer is a little larger than amount.

Keep customer personal data out of the payment's description and metadata: names, email addresses, phone numbers and postal addresses do not belong there, because both are kept with the payment's financial record and cannot always be erased on request. Put your own order id in order_ref and look the customer up on your side.

curl

curl -sS "$RAVELO_API/v1/payment-intents" \
  -H "Authorization: Bearer $RAVELO_TEST_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "order_ref": "quickstart-1",
    "asset_id": "usdt-tron",
    "amount": "25000000",
    "description": "Quickstart order"
  }'

# Copy "id" from the response:
export INTENT_ID=...

# Play the customer:
curl -sS "$RAVELO_API/v1/sandbox/payment-intents/$INTENT_ID/simulate-funding" \
  -H "Authorization: Bearer $RAVELO_TEST_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{}'

# Look at the payment; it succeeds a moment later:
curl -sS "$RAVELO_API/v1/payment-intents/$INTENT_ID" \
  -H "Authorization: Bearer $RAVELO_TEST_KEY"

TypeScript

import { Ravelo, isTestKey } from "@ravelo/sdk";

const apiKey = process.env.RAVELO_TEST_KEY!;
if (!isTestKey(apiKey)) throw new Error("The quickstart needs a test key (rvk_test_...)");

const ravelo = new Ravelo({ apiKey, baseUrl: process.env.RAVELO_API! });

// 25 USDT on TRON, in base units (6 decimals).
const intent = await ravelo.paymentIntents.create({
  order_ref: `quickstart-${Date.now()}`,
  asset_id: "usdt-tron",
  amount: "25000000",
  description: "Quickstart order",
});
console.log("created", intent.id, intent.status, intent.instructions.address);

// Plays the customer: sends what is due to the intent's test address.
const funding = await ravelo.sandbox.simulateFunding(intent.id);
console.log("simulated", funding.amount, funding.asset_id, funding.tx_hash);

// The payment succeeds a moment later. Your webhook endpoint is told; here we also poll.
for (let i = 0; i < 30; i++) {
  const current = await ravelo.paymentIntents.get(intent.id);
  if (current.status === "succeeded") {
    console.log("succeeded", current.amount_received, current.succeeded_at);
    break;
  }
  await new Promise((resolve) => setTimeout(resolve, 1000));
}

Python

import os
import time

from ravelo import Ravelo, is_test_key

api_key = os.environ["RAVELO_TEST_KEY"]
if not is_test_key(api_key):
    raise SystemExit("The quickstart needs a test key (rvk_test_...)")

ravelo = Ravelo(api_key, os.environ["RAVELO_API"])

# 25 USDT on TRON, in base units (6 decimals).
intent = ravelo.payment_intents.create(
    order_ref=f"quickstart-{int(time.time())}",
    asset_id="usdt-tron",
    amount="25000000",
    description="Quickstart order",
)
print("created", intent["id"], intent["status"], intent["instructions"]["address"])

# Plays the customer: sends what is due to the intent's test address.
funding = ravelo.sandbox.simulate_funding(intent["id"])
print("simulated", funding["amount"], funding["asset_id"], funding["tx_hash"])

# The payment succeeds a moment later. Your webhook endpoint is told; here we also poll.
for _ in range(30):
    current = ravelo.payment_intents.get(intent["id"])
    if current["status"] == "succeeded":
        print("succeeded", current["amount_received"], current["succeeded_at"])
        break
    time.sleep(1)
Create response (trimmed)
{
  "id": "0192f1e1-8d3b-7c4e-b2a0-5f6e7d8c9b0a",
  "livemode": false,
  "order_ref": "quickstart-1",
  "status": "requires_payment",
  "asset_id": "usdt-tron",
  "network": "tron",
  "amount": "25000000",
  "instructions": { "address": "T...", "memo": null, "asset_id": "usdt-tron", "network": "tron", "amount": "...", "expires_at": "..." },
  "expires_at": "..."
}
Simulate-funding response
{
  "payment_intent_id": "0192f1e1-8d3b-7c4e-b2a0-5f6e7d8c9b0a",
  "asset_id": "usdt-tron",
  "amount": "...",
  "address": "T...",
  "tx_hash": "...",
  "callback_status": 200
}

callback_status: 200 means the sandbox's signed callback was accepted. The payment becomes succeeded a moment later.

7. Receive and verify the webhook

When the payment succeeds, the receiver prints a line for payment_intent.succeeded with the payment id. That event is the signal to fulfil the order; the delivery looks like this.

Headers your server receives
POST /webhooks/ravelo
Content-Type: application/json
User-Agent: Ravelo-Webhooks/1
Ravelo-Event-Id: 0192f1e2-...
Ravelo-Event-Type: payment_intent.succeeded
Ravelo-Signature: t=1760000000,kid=whk_3f9a1c7d52e0,v1=93754a21211b5c47eb3a6ffbc75604bc021b145c471d2413146a81443435e6f9
Body (trimmed)
{
  "id": "0192f1e2-...",
  "type": "payment_intent.succeeded",
  "created_at": "...",
  "data": { "id": "0192f1e1-8d3b-7c4e-b2a0-5f6e7d8c9b0a", "livemode": false, "status": "succeeded", "order_ref": "quickstart-1", ... }
}
What the receiver prints
verified webhook.test 0192f1e0-...
verified payment_intent.succeeded 0192f1e2-...
paid: 0192f1e1-8d3b-7c4e-b2a0-5f6e7d8c9b0a

You now have a test key, a paid test payment and a verified signature. Call fulfil when you have delivered the order; it records that on the payment and emits payment_intent.fulfilled.

No tunnel?

The event log carries the same events your webhook would receive, so you can see the payment succeed without a public URL. It does not exercise signature verification, so come back to it before you go live.

curl

curl -sS "$RAVELO_API/v1/events?limit=10" \
  -H "Authorization: Bearer $RAVELO_TEST_KEY"

Troubleshooting

You seeWhy and what to do
Nothing arrives after sending the test eventCheck the delivery list: GET /v1/webhook-endpoints/{id}/deliveries shows the status code your server returned or the connection error. The URL must be public https; localhost and private addresses are refused.
rejected: signature does not matchYou are verifying a parsed body or the wrong secret. Pass the raw bytes of the request and the whsec_ secret returned when you registered this endpoint.
rejected: signature timestamp is outside the toleranceYour server clock is more than five minutes off, or you are replaying an old delivery.
403 insufficient_scopeThe key lacks a scope. Create a new test key with payments:read, payments:write and webhooks:manage.
403 test_mode_onlyYou used a live key (rvk_) on a sandbox call. Use the rvk_test_ key.
503 test_mode_unavailableThis deployment has no sandbox. Ask whoever runs your environment to enable test mode.

Next

  • Webhooks: every event, retries, replay and verifying without the SDK.
  • Test mode: short payments, wrong coins and payouts.
  • Idempotency, errors and rate limits before you take live traffic.
  • Going live: create a live key (mode Live), a live webhook endpoint with its own secret, and swap the three environment variables. Nothing else changes.

Money out stays with people

Keys can create payment requests and prepare payouts and refunds. No scope approves money out; a team member does that in the dashboard with a passkey or authenticator code.