สองข้อที่ตัดสินว่า integration ถูกหรือผิด
ทุกอย่างที่เหลือคือรายละเอียด สองข้อนี้คือที่ที่ integration พังจริง
1. Webhook คือความจริง
201 จาก POST /donations แปลว่า “ขอให้จ่าย” ไม่ใช่ “เงินเข้าแล้ว” รอ donation.completed เท่านั้น
2. เงินเป็นสตางค์จำนวนเต็ม
10000 = ฿100.00 อย่า parse เป็น float เด็ดขาด ปัดเศษครั้งเดียวก็ยอดไม่ตรงแล้ว
curl https://donato.click/api/v1/usage \
-H "Authorization: Bearer dnt_live_..."Authentication
Bearer token ธรรมดา แต่มีสองมิติที่พลาดกันบ่อย: environment ผูกกับ host ไม่ใช่กับ header และ scope ผูกกับ key ตอนสร้าง
| Prefix | Host | ใช้ทำอะไร |
|---|---|---|
| dnt_test_ | https://sandbox-api.donato.click/api/v1 | Sandbox — ทุกแพลนใช้ได้ |
| dnt_live_ | https://donato.click/api/v1 | Production — ต้องแพลน Rise ขึ้นไป |
เอา test key ไปยิง production host จะได้ 403 พร้อม type sandbox_only — จงใจให้เป็นแบบนี้ เพราะถ้าปล่อยให้สลับ environment ด้วย header หรือ query ได้ สุดท้ายจะมีคนเข้าใจว่าโดเนทจริงเป็นของทดสอบ
Scope มีสามระดับ: read · write · withdraw — ให้เท่าที่ต้องใช้ ไม่ต้องให้ครบ
curl https://donato.click/api/v1/donations \
-H "Authorization: Bearer $DONATO_API_KEY"Endpoint reference
18 operations — อ่านตรงจาก openapi.json ที่ server เสิร์ฟอยู่
/analytics
get/analytics/overviewRevenue KPIsread
Clamped to the plan analytics retention window.
Parameters
months· query
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/analytics/overview" \
-H "Authorization: Bearer $DONATO_API_KEY"/creator
get/creator/profileThe key owner's profileread
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/creator/profile" \
-H "Authorization: Bearer $DONATO_API_KEY"/donations
get/donationsList donationsread
Newest first, cursor-paginated.
Parameters
limit· querycursor· query — Opaque cursor from a previous response. There is no offset paging.status· queryfrom· queryto· query
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/donations" \
-H "Authorization: Bearer $DONATO_API_KEY"post/donationsCreate a donation requestwrite
Returns a payment URL. NOT a confirmation of payment — wait for the donation.completed webhook.
Parameters
Idempotency-Key· headerrequired — A UUID you generate per intent. Retrying with the same key replays the first response for 24h.
Responses
201— Donation request created400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production409— Idempotency-Key reused with a different body422— Amount outside the allowed range429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X POST "https://donato.click/api/v1/donations" \
-H "Authorization: Bearer $DONATO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{}'get/donations/{id}Get one donationread
Parameters
id· pathrequired
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/donations/{id}" \
-H "Authorization: Bearer $DONATO_API_KEY"get/donations/statisticsDonation statisticsread
Totals bucketed by day, week, or month. Counts only revenue-bearing statuses, matching the creator dashboard.
Parameters
period· queryfrom· queryto· query
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/donations/statistics" \
-H "Authorization: Bearer $DONATO_API_KEY"/events
get/eventsEvent log (catch-up feed)read
Every event this account produced, oldest first. Use it to recover events missed while your server was down: persist the last event_id you processed and pass it as `after`.
Parameters
after· query — Last event_id you processed.event· querylimit· query
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/events" \
-H "Authorization: Bearer $DONATO_API_KEY"/keys
get/keysList API keysread
Metadata only. A key is never readable after it is issued.
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/keys" \
-H "Authorization: Bearer $DONATO_API_KEY"post/keysCreate an API key — not available to API keyswrite
Returns 501. A key that could mint keys would survive revoking the original, so issuing is a dashboard-session operation.
Responses
400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).501— Dashboard session required
curl -X POST "https://donato.click/api/v1/keys" \
-H "Authorization: Bearer $DONATO_API_KEY"delete/keys/{id}Revoke an API keywrite
Idempotent, and allowed where creation is not: revoking narrows access. A key may revoke itself, after which the next call with it returns 401.
Parameters
id· pathrequired
Responses
200— Revoked400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X DELETE "https://donato.click/api/v1/keys/{id}" \
-H "Authorization: Bearer $DONATO_API_KEY"/usage
get/usageCurrent limits and usageread
Exempt from the quotas it reports, so it stays callable when a quota is exhausted.
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/usage" \
-H "Authorization: Bearer $DONATO_API_KEY"/wallet
get/walletWallet balanceread
Balances in satang.
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/wallet" \
-H "Authorization: Bearer $DONATO_API_KEY"/webhooks
get/webhooksList webhook endpointsread
Signing secrets are never included — they are returned once, by POST, and never again.
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/webhooks" \
-H "Authorization: Bearer $DONATO_API_KEY"post/webhooksRegister a webhook endpointwrite
Returns the signing secret ONCE, in `data.secret`. Store it before you read anything else in the response. The environment is decided by the host you called, not by the body.
Responses
201— Endpoint registered; `data.secret` is present exactly this once400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X POST "https://donato.click/api/v1/webhooks" \
-H "Authorization: Bearer $DONATO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{}'delete/webhooks/{id}Delete a webhook endpointwrite
Soft delete — the delivery log stays readable for its retention window.
Parameters
id· pathrequired
Responses
200— Deleted400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production404— No such endpoint429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X DELETE "https://donato.click/api/v1/webhooks/{id}" \
-H "Authorization: Bearer $DONATO_API_KEY"post/webhooks/{id}/testSend a test eventwrite
Delivers a `ping` inline and returns the receiver's own status code and body. A non-2xx there is the answer, not an error on our side.
Parameters
id· pathrequired
Responses
200— Attempted — see `data.delivered`400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production404— No such endpoint429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X POST "https://donato.click/api/v1/webhooks/{id}/test" \
-H "Authorization: Bearer $DONATO_API_KEY"/withdrawals
get/withdrawalsWithdrawal historyread
Parameters
limit· querycursor· query — Opaque cursor from a previous response. There is no offset paging.
Responses
200— Success400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).
curl -X GET "https://donato.click/api/v1/withdrawals" \
-H "Authorization: Bearer $DONATO_API_KEY"post/withdrawalsCreate a withdrawal — not available to API keyswithdraw
Returns 501. Withdrawals require a step-up (AAL2) check that an API key cannot present. Create them from the dashboard.
Responses
400— Invalid request401— Invalid or revoked API key403— Insufficient scope, plan, or a sandbox-only key aimed at production429— Rate limit (type ends with 'rate_limited' — retry after the window) or monthly quota (type ends with 'quota_exceeded' — retrying will not help).501— Step-up authentication required
curl -X POST "https://donato.click/api/v1/withdrawals" \
-H "Authorization: Bearer $DONATO_API_KEY"Webhook event catalogue
สมัครไว้กี่ event ก็ได้ ไม่เลือกเลย = รับทุกอัน การันตีแบบ at-least-once ให้ dedupe ด้วย event_id เสมอ
ทุก delivery คือ POST ที่ body หน้าตาแบบนี้ และมาพร้อม header X-Donato-Event, X-Donato-Event-Id, X-Donato-Delivery-Attempt และ X-Donato-Signature
{
"event": "donation.completed",
"event_id": "evt_...",
"created_at": "2026-01-31T09:12:44.120Z",
"data": {
"amount": 10000,
"currency": "THB",
"donor_name": "...",
"message": "..."
}
}event_id ที่ประมวลผลแล้วไว้ แล้วข้ามตัวซ้ำ อย่าใช้เวลาที่ได้รับเป็นตัวตัดสินSignature verification
Header X-Donato-Signature หน้าตาเป็น t=<unix-seconds>,v1=<hex hmac-sha256> — ต้อง sign กับ raw body เท่านั้น
สิ่งที่ถูก sign คือ `${timestamp}.${rawBody}` — sign the RAW body, not a re-serialised object. ที่ต้องมี timestamp อยู่ในนั้นเพราะลายเซ็นที่ครอบแค่ body จะ valid ตลอดกาล — ใครดัก delivery ได้ครั้งเดียวก็ replay ได้ตลอดชีวิต ฝั่งรับต้องปฏิเสธอะไรที่เก่ากว่า 300 วินาที
JSON.stringify ใหม่ ลายเซ็นจะไม่ตรงทันที เพราะ byte ไม่เหมือนเดิม ต้องอ่าน raw bodyimport { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(
header.split(',').map(p => {
const [k, v] = p.split('=');
return [k?.trim() ?? '', v?.trim() ?? ''] as const;
})
);
const timestamp = Number(parts.get('t'));
const provided = parts.get('v1');
if (!Number.isFinite(timestamp) || !provided) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(provided, 'hex');
// เช็คความยาวก่อน: timingSafeEqual โยน exception ถ้ายาวไม่เท่ากัน
// และ exception ที่โยนออกมาก็เป็น timing signal ในตัวมันเอง
return a.length === b.length && timingSafeEqual(a, b);
}Retry & auto-disable
Timeout 10 วินาทีต่อครั้ง ลองใหม่ 5 รอบ แล้วหยุด
นับว่าสำเร็จเมื่อได้ 2xx เท่านั้น ถ้า endpoint พลาดติดกัน 5 ครั้ง ระบบจะปิดให้อัตโนมัติ แล้วขึ้นเหตุผลไว้ในหน้า Developers — เปิดใหม่ได้เมื่อแก้เสร็จ (การเปิดใหม่จะรีเซ็ตตัวนับให้ด้วย)
ถ้าปิดไปแล้วหรือ server ล่มนานกว่า retry window อย่ามานั่งเดาว่าพลาดอะไรไป — ใช้ /events ดึงย้อนหลัง
Catch-up ด้วย GET /events
Webhook คือ push, endpoint นี้คือ pull — มีไว้สำหรับตอนที่ server คุณล่มไปสองชั่วโมงแล้วโดเนทช่วงนั้นหายไปจากสายตา
เก็บ event_id ตัวสุดท้ายที่ประมวลผลสำเร็จไว้ แล้วส่งเป็น after ตอน start ขึ้นมาใหม่ endpoint นี้เรียงจากเก่าไปใหม่ (ต่างจาก list endpoint อื่นทั้งหมด) เพราะการ replay ต้องเป็นลำดับเดียวกับที่มันเกิดขึ้นจริง
let cursor = await loadLastSeenEventId(); // null ครั้งแรก
let hasMore = true;
while (hasMore) {
const url = new URL('https://donato.click/api/v1/events');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('after', cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.DONATO_API_KEY}` },
});
const { data, meta } = await res.json();
for (const event of data) await handle(event);
cursor = meta.next_after;
hasMore = meta.has_more;
await saveLastSeenEventId(cursor); // save หลัง handle เสมอ ไม่ใช่ก่อน
}Rate limit ของ endpoint นี้ตั้งไว้เป็นสองเท่าของ read ปกติ เพราะมันถูกออกแบบมาให้โดนยิงรัว ๆ ตอนที่ทุกอย่างพังพอดี
Limits
ตัวเลขชุดนี้อ่านจาก constant เดียวกับที่ server บังคับใช้จริง
| Rate limit (req/min) | free | rise | peak |
|---|---|---|---|
| sandbox | 60 | 60 | 120 |
| read | — | 60 | 300 |
| write | — | 20 | 100 |
| withdrawal | — | — | 5 |
| events | — | 120 | 600 |
| alerts | — | 30 | 60 |
| realtime | — | 10 | 30 |
| promptpay | — | 60 | 300 |
| slip | — | 10 | 30 |
| Quota | free | rise | peak |
|---|---|---|---|
| API keys | 1 | 2 | 10 |
| Webhook endpoints | 1 | 3 | 10 |
| Webhook replays / month | 0 | 0 | 100 |
| Webhook log retention (days) | 1 | 7 | 30 |
| Event log retention (days) | 1 | 30 | 90 |
โดน 429 แล้วดู type ใน problem ให้ดี: ลงท้ายด้วย rate_limited คือรอแล้วลองใหม่ได้, ลงท้ายด้วย quota_exceeded คือลองใหม่ไม่ช่วย ต้องรอรอบบิลหรืออัปเกรด
CLI & MCP server
ของสองอย่างนี้ใช้ client layer ตัวเดียวกัน — อันหนึ่งให้คนพิมพ์ อีกอันให้ agent เรียก
donato CLI
ยังไม่ปล่อยบน npmจะปล่อยเป็น @donato-click/cli (คำสั่งชื่อ donato) ตอนนี้อยู่ใน repo ที่ packages/cli — build ด้วย npx tsc -b packages/cli แล้วรัน node packages/cli/dist/index.js
donato login # เก็บ key ไว้ที่ ~/.donato/config.json
donato keys list
donato webhooks create https://your-server.com/hook
donato webhooks test <id>
donato events tail
donato listen --forward localhost:3000/api/donato
donato init # scaffold receiver ที่ verify ลายเซ็นให้แล้วMCP server
ให้ AI agent ตั้ง integration ให้เสร็จเองได้ตั้งแต่ต้นจนจบ — list key, สร้าง endpoint, ยิง test, ไล่ event log · จะปล่อยเป็น @donato-click/mcp ระหว่างนี้ชี้ไปที่ไฟล์ใน repo ได้เลย
{
"mcpServers": {
"donato": {
"command": "node",
"args": ["<repo>/packages/mcp/dist/index.js"],
"env": { "DONATO_API_KEY": "dnt_test_..." }
}
}
}