Skip to content

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)
  })
})

Закрытая партнёрская документация. Не для публичного распространения.