Skip to content

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
}

採用 MIT 等價條款釋出