Integration guide
From zero to signed PDF in five minutes
Create a key, fire a request, subscribe to a webhook, and you're integrated.
Quickstart: create your first contract
Requires an API key with the contracts:write scope.
# Create a contract from a template
curl -X POST https://fastcontracts.com/api/v1/contracts \
-H "Authorization: Bearer $FC_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"template_id": "nda",
"title": "NDA with Acme Corp",
"client_name": "Acme Corp",
"client_email": "[email protected]",
"jurisdiction": "CA",
"form_data": {
"disclosingParty": "FastContracts Inc",
"receivingParty": "Acme Corp"
}
}'The response includes the contract id. Use POST /api/v1/contracts/:id/signatures next to send it for signing.
Webhook receiver
Verify the HMAC-SHA256 signature on every request. Respond 2xx within 30s or we retry.
// app/api/fastcontracts-webhook/route.ts
import { createHmac, timingSafeEqual } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
const WEBHOOK_SECRET = process.env.FC_WEBHOOK_SECRET!
export async function POST(request: NextRequest) {
const rawBody = await request.text()
const signature = request.headers.get('x-fc-signature') ?? ''
const expected = createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
const valid =
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
if (!valid) {
return NextResponse.json({ error: 'bad signature' }, { status: 401 })
}
const event = JSON.parse(rawBody)
// Respond fast — queue any heavy work for a worker
switch (event.type) {
case 'contract.completed':
console.log('Completed:', event.data.contract_id, event.data.pdf_url)
break
case 'signature.signed':
console.log(event.data.party_email, 'signed')
break
}
return NextResponse.json({ received: true })
}Same pattern works in Express, Fastify, Flask, or Rails — the only requirement is access to the raw request body before any JSON parsing.
Event reference
Every envelope includes id, type, api_version, created_at, livemode, and data.
| Event | When it fires |
|---|---|
| contract.created | Fires on every new contract (UI, AI create, or API). |
| contract.updated | Draft edits to title, fields, or content. |
| contract.deleted | Contract removed from the account. |
| contract.sent | Sent for signature. Carries signing mode + party count. |
| contract.completed | All parties signed. Payload includes a 1-hour signed PDF URL. |
| contract.declined | A party declined; ceremony halted. |
| contract.expired | All active signature invites expired without completion. |
| signature.requested | Invite created for a specific party. |
| signature.viewed | Signer opened the signing page. |
| signature.signed | Individual party completed signing (not the last one). |
| signature.declined | A single party declined. |
| email.delivered | Resend confirms delivery to the recipient MTA. |
| email.bounced | Hard or soft bounce at the recipient. |
| email.opened | Pixel opened (indicative, not auth). |
Error model
Every failing response uses this shape: { error: { code, message, details, request_id } }
| Code | HTTP | What to do |
|---|---|---|
| UNAUTHORIZED | 401 | Missing or malformed Authorization header. |
| INVALID_API_KEY | 401 | Key revoked, expired, or unknown. |
| INSUFFICIENT_SCOPE | 403 | Key lacks the required scope — rotate or create a new key. |
| RATE_LIMIT_EXCEEDED | 429 | Respect Retry-After; back off. |
| QUOTA_EXCEEDED | 429 | Monthly cap hit for this resource (contracts, signatures, pdfs). |
| IDEMPOTENCY_KEY_CONFLICT | 422 | Same Idempotency-Key used with a different body. Use a new UUID. |
| VALIDATION_ERROR | 400 | Check the details.field to see which field failed. |
| INTERNAL_ERROR | 500 | Retry with exponential backoff; include X-Request-Id if reporting. |