Тема
Node.js пример
Production-ready клиент для inbound + handler для callback'ов.
Установка
bash
npm i undici(можно и axios / node-fetch — главное, чтобы вы могли отдать строку в body)
Клиент: залить лид
ts
// src/cartelcrm-client.ts
import crypto from 'node:crypto'
import { request } from 'undici'
interface LeadPayload {
external_id: string
phone: string
email?: string
first_name?: string
last_name?: string
country?: string
source?: string
funnel?: string
sub_id?: string
click_id?: string
[key: string]: unknown
}
interface LeadResponse {
id: string | null
status: 'accepted' | 'duplicate' | 'validation_error' | 'rejected'
reason: string | null
duration_ms?: number
replay?: boolean
}
export class CartelCrmClient {
constructor(
private readonly baseUrl: string,
private readonly apiKey: string,
private readonly hmacSecret: string,
) {}
async submitLead(
payload: LeadPayload,
idempotencyKey: string,
): Promise<{ status: number; body: LeadResponse }> {
// Важно: сериализуем один раз и шлём ту же строку.
const body = JSON.stringify(payload)
const ts = Math.floor(Date.now() / 1000).toString()
const sig = crypto
.createHmac('sha256', this.hmacSecret)
.update(body)
.digest('hex')
const res = await request(`${this.baseUrl}/v1/inbound/leads`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'X-Signature': `sha256=${sig}`,
'X-Timestamp': ts,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body,
})
const json = (await res.body.json()) as LeadResponse
return { status: res.statusCode, body: json }
}
async getStatus(externalId: string) {
const ts = Math.floor(Date.now() / 1000).toString()
const sig = crypto
.createHmac('sha256', this.hmacSecret)
.update('') // пустое body
.digest('hex')
const res = await request(
`${this.baseUrl}/v1/inbound/leads/${encodeURIComponent(externalId)}/status`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'X-Signature': `sha256=${sig}`,
'X-Timestamp': ts,
},
},
)
return { status: res.statusCode, body: await res.body.json() }
}
}Использование с retry
ts
import { CartelCrmClient } from './cartelcrm-client'
const client = new CartelCrmClient(
process.env.CARTELCRM_BASE_URL!,
process.env.CARTELCRM_API_KEY!,
process.env.CARTELCRM_HMAC_SECRET!,
)
async function submitWithRetry(payload, attempts = 3) {
// Сохраните idempotencyKey ВНЕ цикла ретраев,
// чтобы каждая попытка использовала одинаковый ключ.
const idempotencyKey = `${payload.external_id}-${Date.now()}`
for (let i = 1; i <= attempts; i++) {
try {
const res = await client.submitLead(payload, idempotencyKey)
if (res.status === 200) return { ok: true, ...res }
if (res.status === 409) return { ok: true, duplicate: true, ...res }
if (res.status === 429) {
await new Promise(r => setTimeout(r, 30_000))
continue
}
if (res.status >= 500) {
await new Promise(r => setTimeout(r, 2 ** i * 1000))
continue
}
return { ok: false, ...res } // 4xx — не ретраим
} catch (err) {
if (i === attempts) throw err
await new Promise(r => setTimeout(r, 2 ** i * 1000))
}
}
}Сервер: callback handler (Express)
Главная тонкость — нужен сырой body для подписи. Express по умолчанию его не отдаёт.
ts
// src/callback-server.ts
import express from 'express'
import crypto from 'node:crypto'
const app = express()
// Парсим body, но сохраняем сырые байты в req.rawBody
app.use(
express.json({
verify: (req: any, _res, buf) => {
req.rawBody = buf.toString('utf8')
},
}),
)
function verifySignature(
rawBody: string,
sigHeader: string | undefined,
secret: string,
): boolean {
if (!sigHeader) return false
const cleanSig = sigHeader.startsWith('sha256=') ? sigHeader.slice(7) : sigHeader
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
if (cleanSig.length !== expected.length) return false
return crypto.timingSafeEqual(
Buffer.from(cleanSig, 'hex'),
Buffer.from(expected, 'hex'),
)
}
app.post('/cartelcrm/callback', (req: any, res) => {
const rawBody = req.rawBody
const sigHeader = req.headers['x-signature'] as string | undefined
const ts = Number(req.headers['x-timestamp'] ?? 0)
// Anti-replay
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).json({ error: 'timestamp_out_of_range' })
}
// Signature
if (!verifySignature(rawBody, sigHeader, process.env.CALLBACK_SECRET!)) {
return res.status(403).json({ error: 'bad_signature' })
}
// Идемпотентность на нашей стороне: ключ — (internal_id, event)
const event = req.body
const idKey = `${event.internal_id}:${event.event}:${event.timestamp}`
if (alreadySeen(idKey)) {
return res.status(200).json({ ok: true, replay: true })
}
// Кладём в нашу очередь и сразу отвечаем
enqueue(event)
res.status(200).json({ ok: true })
})
const PORT = Number(process.env.PORT ?? 8080)
app.listen(PORT, () => console.log(`listening on :${PORT}`))
// — заглушки —
function alreadySeen(_key: string): boolean { return false }
function enqueue(_event: unknown): void { /* push to queue */ }Сохраняйте rawBody всегда
Это ключевой момент. Если вы прочитаете req.body после express.json() (объект) и вызовете JSON.stringify(req.body) — получите другую строку, и подпись не совпадёт.
Тесты
ts
// vitest пример
import { describe, it, expect } from 'vitest'
import crypto from 'node:crypto'
describe('HMAC parity', () => {
it('our calc matches openssl', () => {
const body = '{"phone":"+380501234567"}'
const secret = 'test_secret'
const ours = crypto.createHmac('sha256', secret).update(body).digest('hex')
const expected = '78d0e3...' // вычислите один раз через `openssl dgst -sha256 -hmac test_secret`
expect(ours).toBe(expected)
})
})