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:3000over public https, such ascloudflared tunnel --url http://localhost:3000orngrok http 3000. Webhook URLs must be public https, so the sandbox cannot call your laptop directly.
1. Create a test key
- Open API & apps in the dashboard and choose New API key.
- Set Mode to Test and tick
payments:read,payments:writeandwebhooks:manage. - Confirm with your passkey or authenticator code, then copy the secret. It starts with
rvk_test_and is shown once.
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.
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.
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"
}'{
"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.
export RAVELO_WEBHOOK_SECRET=whsec_...
export ENDPOINT_ID=<the id above>
npx tsx webhook-server.ts # TypeScript
python webhook_server.py # Python5. 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){
"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": "..."
}{
"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.
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{
"id": "0192f1e2-...",
"type": "payment_intent.succeeded",
"created_at": "...",
"data": { "id": "0192f1e1-8d3b-7c4e-b2a0-5f6e7d8c9b0a", "livemode": false, "status": "succeeded", "order_ref": "quickstart-1", ... }
}verified webhook.test 0192f1e0-...
verified payment_intent.succeeded 0192f1e2-...
paid: 0192f1e1-8d3b-7c4e-b2a0-5f6e7d8c9b0aYou 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 see | Why and what to do |
|---|---|
| Nothing arrives after sending the test event | Check 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 match | You 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 tolerance | Your server clock is more than five minutes off, or you are replaying an old delivery. |
| 403 insufficient_scope | The key lacks a scope. Create a new test key with payments:read, payments:write and webhooks:manage. |
| 403 test_mode_only | You used a live key (rvk_) on a sandbox call. Use the rvk_test_ key. |
| 503 test_mode_unavailable | This 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