Node.js
相容性
Node.js ≥ 18(原生 fetch)。無第三方依賴。
客戶端實現
js
import crypto from 'node:crypto'
const APP_ID = 'cp_a1b2c3d4...'
const SECRET = 'f1e2d3c4...'
const BASE = 'https://api.coinepay.net'
function sign(method, path, body) {
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = crypto.randomBytes(16).toString('hex')
const bodyHash = crypto.createHash('sha256').update(body).digest('hex')
const signInput = `${method}\n${path}\n\n${ts}\n${nonce}\n${bodyHash}`
const sig = crypto.createHmac('sha256', SECRET).update(signInput).digest('hex')
return {
'X-App-Id': APP_ID,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': sig,
}
}
export async function call(path, bodyDict, idempotencyKey) {
const body = JSON.stringify(bodyDict ?? {})
const headers = {
...sign('POST', path, body),
'Content-Type': 'application/json',
}
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey
const res = await fetch(BASE + path, { method: 'POST', headers, body })
return res.json()
}
// Webhook 驗籤
export function verifyWebhook(sigHeader, tsHeader, rawBody, webhookSecret) {
if (!sigHeader || !sigHeader.startsWith('v1,')) return false
const sig = sigHeader.slice(3)
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(`${tsHeader}.`)
.update(rawBody)
.digest('hex')
// 恆定時間比較
return (
Buffer.byteLength(sig) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
)
}使用示例
js
import { randomUUID } from 'node:crypto'
import { call } from './coinepay.js'
// 1. 列卡頭
let resp = await call('/api/v1/openapi/card_headers/list', { page: 1, page_size: 20 })
console.log(resp.data.list[0].header_id)
// 2. 列卡配置
resp = await call('/api/v1/openapi/card_configs/list', { header_id: 'hdr_5' })
const packageId = resp.data.list[0].package_id
// 3. 申請虛擬卡(帶冪等鍵)
resp = await call(
'/api/v1/openapi/card/apply',
{
header_id: 'hdr_5',
package_id: packageId,
first_name: 'John',
last_name: 'Doe',
},
randomUUID(),
)
const cardId = resp.data.card_id
console.log(`Created card: ${cardId}`)
// 4. 建立充值
resp = await call(
'/api/v1/openapi/card/recharge',
{ card_id: cardId, amount: '100.00' },
randomUUID(),
)
console.log(`Transaction: ${resp.data.transaction_id}`)Webhook 接收(Express)
js
import express from 'express'
import { verifyWebhook } from './coinepay.js'
const app = express()
const WEBHOOK_SECRET = process.env.COINEPAY_WEBHOOK_SECRET
// 關鍵:raw body,不是 JSON parse 後的物件
app.post('/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.header('Webhook-Signature') || ''
const ts = req.header('Webhook-Timestamp') || ''
const eventId = req.header('Webhook-Id') || ''
const eventType = req.header('Webhook-Type') || ''
if (!verifyWebhook(sig, ts, req.body, WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature')
}
// 時間戳 ±5 分鐘
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) {
return res.status(401).send('timestamp expired')
}
// event_id 去重
if (await alreadyProcessed(eventId)) return res.sendStatus(200)
const payload = JSON.parse(req.body.toString('utf8'))
await enqueue(eventType, payload) // 重活丟佇列
await markProcessed(eventId)
res.sendStatus(200)
})
app.listen(3000)Webhook 接收(Fastify)
js
import Fastify from 'fastify'
import { verifyWebhook } from './coinepay.js'
const fastify = Fastify()
// 保留 raw body
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
req.rawBody = body
try {
done(null, JSON.parse(body.toString('utf8')))
} catch (e) {
done(e)
}
},
)
fastify.post('/webhook', async (req, reply) => {
const sig = req.headers['webhook-signature'] || ''
const ts = req.headers['webhook-timestamp'] || ''
if (!verifyWebhook(sig, ts, req.rawBody, process.env.COINEPAY_WEBHOOK_SECRET)) {
return reply.code(401).send('invalid signature')
}
await enqueue(req.headers['webhook-type'], req.body)
return { ok: true }
})
fastify.listen({ port: 3000 })TypeScript 型別
ts
export interface ApiResponse<T = unknown> {
code: number
message: string
message_key?: string
data: T | null
}
export interface PageData<T> {
list: T[]
total: number
page: number
page_size: number
total_pages: number
has_next: boolean
has_prev: boolean
}
export interface CardInfo {
card_id: string
card_type: 'virtual_l' | 'virtual_p' | 'virtual_v' | 'virtual_r' | 'virtual_g' | 'virtual_a'
card_brand: string
currency: string
status: 1 | 2 | 3 | 4 | 5 | 6
status_desc: string
masked_card_no: string
last_four: string
balance: string
frozen_balance: string
activated_at: string | null
created_at: string
}