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.

EventWhen it fires
contract.createdFires on every new contract (UI, AI create, or API).
contract.updatedDraft edits to title, fields, or content.
contract.deletedContract removed from the account.
contract.sentSent for signature. Carries signing mode + party count.
contract.completedAll parties signed. Payload includes a 1-hour signed PDF URL.
contract.declinedA party declined; ceremony halted.
contract.expiredAll active signature invites expired without completion.
signature.requestedInvite created for a specific party.
signature.viewedSigner opened the signing page.
signature.signedIndividual party completed signing (not the last one).
signature.declinedA single party declined.
email.deliveredResend confirms delivery to the recipient MTA.
email.bouncedHard or soft bounce at the recipient.
email.openedPixel opened (indicative, not auth).

Error model

Every failing response uses this shape: { error: { code, message, details, request_id } }

CodeHTTPWhat to do
UNAUTHORIZED401Missing or malformed Authorization header.
INVALID_API_KEY401Key revoked, expired, or unknown.
INSUFFICIENT_SCOPE403Key lacks the required scope — rotate or create a new key.
RATE_LIMIT_EXCEEDED429Respect Retry-After; back off.
QUOTA_EXCEEDED429Monthly cap hit for this resource (contracts, signatures, pdfs).
IDEMPOTENCY_KEY_CONFLICT422Same Idempotency-Key used with a different body. Use a new UUID.
VALIDATION_ERROR400Check the details.field to see which field failed.
INTERNAL_ERROR500Retry with exponential backoff; include X-Request-Id if reporting.