Developers

The iQuote API

Read and write customers, quotes, invoices and payments, and get signed webhooks when they change. Your users connect their iQuote businesses with one click; no passwords change hands.

Overview

A JSON API over HTTPS. Plain HTTP is refused, not redirected. Every request is made on behalf of a person who is an owner or admin of the business, and runs under the same rules as the app: numbering, totals, deposits, plan limits and who may move money.

The API is part of the Pro, Multi-Business, Enterprise, Infinity plans. A business on Free or Starter answers 402 plan_required.

Base URL
https://i-quote.app/api/v1
API at a glance
TopicDetail
AuthenticationAuthorization: Bearer with an OAuth access token iqat_… or an API key iqk_…. Passwords and iQuote session tokens are never accepted.
BusinessIQuote-Business: <business id> on each request when a connection covers several businesses.
FormatJSON bodies (Content-Type: application/json, up to 1 MB). Amounts are numbers in the document currency; dates are YYYY-MM-DD or ISO 8601.
IdsUUIDs. Every object carries id and object (customer, quote, invoice, payment …).
Spechttps://i-quote.app/openapi.json (OpenAPI 3.1, includes webhooks).

Quick check with an API key

curl
curl https://i-quote.app/api/v1/me \
  -H "Authorization: Bearer $IQUOTE_TOKEN"

Connect with iQuote (OAuth 2.0)

For apps used by many iQuote businesses. The authorization code flow with PKCE (S256), required for every client. The user signs in on iQuote itself and approves what your app may do; you never see their password.

  1. 1

    Send the user to the consent page

    Redirect the browser to https://i-quote.app/oauth/authorize with response_type=code, your client_id, a registered redirect_uri (matched exactly), the scopes you need (space-separated; leave scope out to ask for all your app is allowed), a random state, and code_challenge with code_challenge_method=S256.

  2. 2

    The user chooses businesses and approves

    They sign in to iQuote, pick one or several of their businesses to connect, and approve the scopes. Only businesses where they are an owner or admin on Pro or higher can be picked.

  3. 3

    iQuote redirects back

    Your redirect_uri receives code, state and iss. Check that state is the one you sent and iss is https://i-quote.app. A declined request comes back with error=access_denied. The code works once, for 5 minutes.

  4. 4

    Exchange the code for tokens

    POST to https://i-quote.app/oauth/token with grant_type=authorization_code, the code, the same redirect_uri and the code_verifier. Form-encoded or JSON. Confidential clients authenticate with HTTP Basic or client_id and client_secret in the body; public clients send client_id only. The answer lists the connected businesses as businesses: [{ id, name }].

  5. 5

    Call the API

    Send Authorization: Bearer <access_token> and, when several businesses are connected, IQuote-Business: <business id>. Access tokens last one hour.

  6. 6

    Refresh, and disconnect

    grant_type=refresh_token returns a new access token and a new refresh token (30 days). Refresh tokens rotate: using one twice revokes the whole connection, so store the new one every time. To disconnect, POST the refresh token to https://i-quote.app/oauth/revoke (RFC 7009). Users can also disconnect your app from iQuote at any time.

1. PKCE and the authorize URL

# The authorize step happens in the user's browser, not with curl.
# Generate a verifier and its S256 challenge in a shell:
CODE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)

# Then open this URL in a browser (scope is space-separated, %20 encoded):
echo "https://i-quote.app/oauth/authorize?response_type=code&client_id=$IQUOTE_CLIENT_ID&redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback%2Fiquote&scope=customers%3Aread%20invoices%3Aread%20invoices%3Awrite&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"

2. Exchange the code

# Back on your redirect_uri: ?code=iqac_...&state=...&iss=https://i-quote.app
# Check that state is the one you stored and iss is https://i-quote.app, then:
curl https://i-quote.app/oauth/token \
  -u "$IQUOTE_CLIENT_ID:$IQUOTE_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=https://yourapp.example/callback/iquote \
  -d code_verifier="$CODE_VERIFIER"

# Public client (no secret): drop -u and send -d client_id="$IQUOTE_CLIENT_ID"

3. Refresh and revoke

curl https://i-quote.app/oauth/token \
  -u "$IQUOTE_CLIENT_ID:$IQUOTE_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN"
# Optional: -d scope="invoices:read" to narrow the new access token.
# Save the NEW refresh_token from the answer. The old one is now spent.

# Disconnect (RFC 7009). A refresh token ends the whole connection:
curl https://i-quote.app/oauth/revoke \
  -u "$IQUOTE_CLIENT_ID:$IQUOTE_CLIENT_SECRET" \
  -d token="$REFRESH_TOKEN"

Access stays tied to the user. If they stop being an owner or admin of a business, or the business leaves Pro or higher, calls for it fail (403 or 402) even with a valid token. GET /v1/businesses shows the state of each connected business.

Register an app

An owner or admin of a business on Pro or higher registers apps in Settings → Developers. You get a client_id (iqc_…) and, for a confidential client, a client_secret (iqcs_…) that is shown once; rotate it there if it leaks.

Confidential clients run on a server that can keep a secret. Public clients (mobile, desktop, single-page apps) have no secret and rely on PKCE alone.

Redirect URIs must be https, or http on localhost, 127.0.0.1 or [::1] for development. They are matched exactly: no wildcards, no fragments. Choose the scopes the app may ask for; a request for any other scope is refused.

The consent page shows users your app name, description, homepage and the business that registered it.

API keys

For your own scripts and integrations on one business. An owner or admin creates a key in Settings → Developers, chooses its scopes and, optionally, when it expires. The key (iqk_…) is shown once. It acts as the person who created it, so it stops working if they lose owner or admin rights, and it can be revoked at any time.

A key belongs to exactly one business, so the IQuote-Business header is not needed. Keep keys on a server; never ship one in a browser or mobile app (use Connect with iQuote there).

Scopes

A write scope does not include read: ask for both when you need both. The wording is what users see on the consent page.

Scopes
ScopeAllows
customers:readSee your customers
customers:writeCreate, change and delete customers
quotes:readSee your quotes
quotes:writeCreate, change, send and convert quotes
invoices:readSee your invoices and reports
invoices:writeCreate, change, send and void invoices, send reminders and record payments received
refunds:writeRefund card payments to your customers
payments:readSee payments received
webhooks:manageManage webhook endpoints that receive your business events

GET /v1/me and GET /v1/businesses need no scope. GET /v1/reports/summary needs invoices:read and adds quote figures when the token also has quotes:read.

Choosing a business

One OAuth connection can cover several businesses: the user picks them on the consent page, and the token response lists them. Name the business on each request with the IQuote-Business header (or ?business_id=). With only one connected business it can be left out.

Leaving it out when several are connected answers 400 business_required with the connected ids in error.business_ids. A business the connection does not cover answers 403 business_not_connected.

# Which businesses does this connection reach?
curl https://i-quote.app/api/v1/businesses \
  -H "Authorization: Bearer $IQUOTE_TOKEN"

# Overdue invoices of one of them, 50 per page
curl "https://i-quote.app/api/v1/invoices?status=overdue&limit=50" \
  -H "Authorization: Bearer $IQUOTE_TOKEN" \
  -H "IQuote-Business: $BUSINESS_ID"

# Next page: add &cursor=<next_cursor from the previous answer>

Rate limits

120 requests per minute per access token or API key, and 600 per minute per business across every app and key. Over the limit, the answer is 429 rate_limited with a Retry-After header in seconds.

Every authenticated answer carries X-RateLimit-Limit and X-RateLimit-Remaining. Sending quote, invoice and reminder emails also counts against the business’s daily email budget (429 email_rate_limited).

Idempotency

Send an Idempotency-Key header (1-255 printable characters, e.g. a UUID) on POST, PATCH and DELETE. For 24 hours, repeating the request with the same key returns the first answer with Idempotent-Replayed: true instead of doing the work again: no second invoice, no second email.

While the first request is still running, a repeat answers 409 idempotency_in_progress. The same key with a different method, path or body answers 422 idempotency_key_reused. Keys are scoped to the credential and business. Server errors (5xx) are not stored, so retrying them runs the request again.

curl https://i-quote.app/api/v1/invoices \
  -H "Authorization: Bearer $IQUOTE_TOKEN" \
  -H "IQuote-Business: $BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-invoice" \
  -d '{
    "customer_id": "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b",
    "currency": "USD",
    "due_date": "2026-11-15",
    "line_items": [
      { "description": "Website redesign", "quantity": 1, "unit_price": 2400 },
      { "description": "Hosting (monthly)", "quantity": 1, "unit_price": 30 }
    ],
    "deposit": { "type": "percent", "value": 30 }
  }'

# Same key again within 24 hours: the first answer is replayed
# (Idempotent-Replayed: true) and no second invoice is created.
# Then email it:  curl -X POST https://i-quote.app/api/v1/invoices/<id>/send ... -H "Idempotency-Key: order-1042-send"

Pagination

Lists are newest first. limit sets the page size (1-100, default 25). When has_more is true, pass next_cursor as cursor to get the next page. Cursors are opaque: do not build them yourself.

JSON
{
  "object": "list",
  "data": [ { "id": "...", "object": "invoice", ... } ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0xMC0xNlQwOToyMDowMC4wMDBaIiwiaSI6Ii4uLiJ9"
}

Errors

Errors use HTTP status codes and one JSON shape. Branch on error.code, which is stable; error.message is for people. Quote error.request_id (also the IQuote-Request-Id header) when you contact support.

HTTP
HTTP/1.1 403 Forbidden
IQuote-Request-Id: req_8f2a1c9d4b7e6a3f0c5d2e1b

{
  "error": {
    "type": "permission_error",
    "code": "insufficient_scope",
    "message": "This token is missing the invoices:write scope",
    "required_scope": "invoices:write",
    "request_id": "req_8f2a1c9d4b7e6a3f0c5d2e1b"
  }
}
Errors
Statuserror.typeerror.codeMeaning
400invalid_requestinvalid_json, missing_field, invalid_value, unknown_field, invalid_id, invalid_limit, invalid_cursor, invalid_business, business_required, invalid_idempotency_key, invalid_line_items, too_many, amount_too_high, invalid_url, invalid_events, refund_refused, idempotency_key_required, invalid_pathThe request is malformed or a value is out of range. param names the field when there is one. refund_refused: Stripe is not connected, or nothing on the invoice can be refunded.
401authentication_errormissing_token, invalid_tokenNo credential, or one that is unknown, expired or revoked. Refresh the access token or reconnect.
402plan_errorplan_required, plan_limit_reachedThe business is not on Pro or higher, or a plan limit was reached.
403permission_errorhttps_required, insufficient_scope, business_not_connected, not_a_member, insufficient_role, business_blocked, forbiddenThe credential may not do this: a missing scope, a business it is not connected to, a user who is no longer an owner or admin, or a suspended business.
404not_foundroute_not_found, not_found, business_not_foundNo such endpoint, or no such object in this business.
405invalid_requestmethod_not_allowedThe path exists but not with this method.
409conflictidempotency_in_progress, conflict, duplicate_email, duplicate_external_id, customer_has_history, quote_not_editable, quote_not_draft, quote_not_sendable, quote_not_convertible, customer_no_email, invoice_not_editable, invoice_not_draft, invoice_has_payments, invoice_not_sendable, invoice_not_payable, invoice_not_voidable, not_recurring, card_payment, too_many_endpoints, business_suspended, business_lockedThe object is not in a state that allows this, the same rules the app applies.
413invalid_requestbody_too_largeThe body is larger than 1 MB.
415invalid_requestunsupported_media_typeSend JSON with Content-Type: application/json.
422invalid_requestidempotency_key_reused, invalid_reference, invalid_value, rule_violationWell-formed but refused: an Idempotency-Key reused with a different request, a reference to an object of another business, or a database rule.
429rate_limit_errorrate_limited, email_rate_limited, refund_rate_limitedToo many requests, the business reached its daily email budget, or its hourly API refund limit. Wait for Retry-After.
500api_errorinternal_error, database_error, partial_failure, convert_failed, refund_failedSomething failed on our side. Retrying with the same Idempotency-Key is safe.
502upstream_erroremail_failed, stripe_errorThe email provider or Stripe refused or failed. Nothing was marked sent or refunded.
503service_unavailablenot_configured, auth_unavailable, idempotency_unavailableTemporarily unavailable. Retry with backoff.

The OAuth endpoints answer in the OAuth format instead: { "error": "invalid_grant", "error_description": "…" }.

Endpoints

All paths are relative to https://i-quote.app/api/v1. 43 endpoints; the OpenAPI file has the full request and response schemas.

Credential and businesses

Who the credential is, what it may do, and which businesses it reaches. Neither endpoint needs a scope or the IQuote-Business header.

GET

/v1/me

Describe this credential

Credential type (OAuth access token or API key), the user it acts as, its scopes and the business ids it is connected to.

No scope neededReturns 200 Credential
GET

/v1/businesses

List connected businesses

Every business this credential is connected to, with the user's role, the plan, and whether the API can be used on it right now (api_access, and api_access_error when not).

No scope neededReturns 200 BusinessList

Customers

The same rules as the customer form in the app: one customer per email per business, and a customer with quotes, invoices or payments cannot be deleted.

GET

/v1/customers

List customers

Newest first.

customers:readIQuote-BusinessReturns 200 CustomerList
POST

/v1/customers

Create a customer

customers:writeIQuote-BusinessReturns 201 Customer
POST

/v1/customers/bulk_upsert

Create or update up to 100 customers

Rows are matched by external_id, then email (match_on), updated when matched and created otherwise. One result per row, in order; a bad row does not stop the others.

customers:writeIQuote-BusinessReturns 200 BulkResult
GET

/v1/customers/{id}

Retrieve a customer

customers:readIQuote-BusinessReturns 200 Customer
PATCH

/v1/customers/{id}

Update a customer

customers:writeIQuote-BusinessReturns 200 Customer
DELETE

/v1/customers/{id}

Delete a customer

Refused (409 customer_has_history) when the customer has quotes, invoices or payments.

customers:writeIQuote-BusinessReturns 200 Deleted

Quotes

Quotes are created as drafts, numbered and totalled like the app does. Sending emails the customer from the business, in its branding.

GET

/v1/quotes

List quotes

Newest first.

quotes:readIQuote-BusinessReturns 200 QuoteList
POST

/v1/quotes

Create a draft quote

quotes:writeIQuote-BusinessReturns 201 Quote
GET

/v1/quotes/{id}

Retrieve a quote

quotes:readIQuote-BusinessReturns 200 Quote
PATCH

/v1/quotes/{id}

Update a quote

Accepted and converted quotes are records and cannot be edited (409 quote_not_editable); duplicate them instead.

quotes:writeIQuote-BusinessReturns 200 Quote
DELETE

/v1/quotes/{id}

Delete a draft quote

Only drafts (409 quote_not_draft).

quotes:writeIQuote-BusinessReturns 200 Deleted
POST

/v1/quotes/{id}/send

Email the quote to the customer

Draft, sent and viewed quotes only. Emails first, then marks the quote sent (a resend keeps its status). Needs a customer email. Retry with the same Idempotency-Key to avoid a second email.

quotes:writeIQuote-BusinessReturns 200 Quote
POST

/v1/quotes/{id}/duplicate

Copy into a new draft

quotes:writeIQuote-BusinessReturns 201 Quote
POST

/v1/quotes/{id}/convert

Convert to an invoice

Sent, viewed and accepted quotes can be converted. Returns the invoice: 201 when created, 200 when the quote was already converted (idempotent).

quotes:writeIQuote-BusinessReturns 201/200 Invoice

Invoices

Invoices can be edited until money moves on them. Payments recorded by hand, refunds and voids follow the app's money rules.

GET

/v1/invoices

List invoices

Newest first.

invoices:readIQuote-BusinessReturns 200 InvoiceList
POST

/v1/invoices

Create a draft invoice

invoices:writeIQuote-BusinessReturns 201 Invoice
GET

/v1/invoices/{id}

Retrieve an invoice

invoices:readIQuote-BusinessReturns 200 Invoice
PATCH

/v1/invoices/{id}

Update an invoice

Only while the invoice is draft, sent, viewed or overdue with nothing paid (409 invoice_not_editable).

invoices:writeIQuote-BusinessReturns 200 Invoice
DELETE

/v1/invoices/{id}

Delete a draft invoice

Only drafts with no payments. Void a sent invoice instead.

invoices:writeIQuote-BusinessReturns 200 Deleted
POST

/v1/invoices/{id}/send

Email the invoice to the customer

Sets the issue and due dates the way the app does on first send, emails, then marks the invoice sent. Retry with the same Idempotency-Key to avoid a second email.

invoices:writeIQuote-BusinessReturns 200 Invoice
POST

/v1/invoices/{id}/remind

Send a payment reminder

A "due soon" reminder before the due date, an overdue notice after it. Only on invoices awaiting payment.

invoices:writeIQuote-BusinessReturns 200 InvoiceWithReminder
POST

/v1/invoices/{id}/pause_reminders

Pause or resume automatic reminders

invoices:writeIQuote-BusinessReturns 200 Invoice
POST

/v1/invoices/{id}/stop_recurring

Stop a recurring series

Call it on the first invoice of the series. No further invoices are created.

invoices:writeIQuote-BusinessReturns 200 Invoice
POST

/v1/invoices/{id}/void

Void an invoice

Only without money on it: refund or remove payments first.

invoices:writeIQuote-BusinessReturns 200 Invoice
POST

/v1/invoices/{id}/refund

Refund card payments

Refunds card payments through the business's connected Stripe account. Owner or admin only. Requires an Idempotency-Key header (400 idempotency_key_required). At most 20 API refunds per business per hour (429 refund_rate_limited). The business is notified of every API refund. pending is true while Stripe is still processing.

refunds:writeIQuote-BusinessReturns 200 Refund
POST

/v1/invoices/{id}/payment_link

Get the customer payment page

The hosted page where the customer views and pays the invoice, and whether it can take a card payment now (payable, reason). Changes nothing.

invoices:readIQuote-BusinessReturns 200 PaymentLink
GET

/v1/invoices/{id}/payments

List the invoice's payments

All of them, newest first (not paginated).

invoices:readIQuote-BusinessReturns 200 PaymentList
POST

/v1/invoices/{id}/payments

Record a payment received outside iQuote

Requires an Idempotency-Key header (400 idempotency_key_required), so a retry never records the money twice.

invoices:writeIQuote-BusinessReturns 201 RecordedPayment
DELETE

/v1/invoices/{id}/payments/{payment_id}

Remove a recorded payment

Only payments recorded by hand, and only by an owner or admin. Card payments are refunded instead (409 card_payment).

invoices:writeIQuote-BusinessReturns 200 DeletedPayment

Payments

Money received: card payments through Stripe and payments recorded by hand.

GET

/v1/payments

List payments

Newest first.

payments:readIQuote-BusinessReturns 200 PaymentList
GET

/v1/payments/{id}

Retrieve a payment

payments:readIQuote-BusinessReturns 200 Payment

Reports

Money per currency, never added across currencies.

GET

/v1/reports/summary

Summary for a period

invoiced, collected (net of refunds), refunded, outstanding and overdue per currency, plus counts. With quotes:read as well, quote counts by status and the acceptance rate.

invoices:readIQuote-BusinessReturns 200 ReportSummary

Webhook endpoints

Up to 10 endpoints per business. The signing secret is returned only when an endpoint is created and when its secret is rotated. Through the API an app or key sees and manages only the endpoints it created, may only subscribe to events it can read (customer.* needs customers:read, quote.* quotes:read, invoice.* invoices:read, payment.* payments:read; * needs all four), and its endpoints are disabled when the connection or key is revoked. Endpoints added in Settings belong to the business.

GET

/v1/webhook_endpoints

List webhook endpoints

The endpoints this app or key created (not paginated).

webhooks:manageIQuote-BusinessReturns 200 WebhookEndpointList
POST

/v1/webhook_endpoints

Create a webhook endpoint

The response includes the signing secret (whsec_...). Store it: it is not shown again.

webhooks:manageIQuote-BusinessReturns 201 WebhookEndpointWithSecret
GET

/v1/webhook_endpoints/{id}

Retrieve a webhook endpoint

webhooks:manageIQuote-BusinessReturns 200 WebhookEndpoint
PATCH

/v1/webhook_endpoints/{id}

Update a webhook endpoint

webhooks:manageIQuote-BusinessReturns 200 WebhookEndpoint
DELETE

/v1/webhook_endpoints/{id}

Delete a webhook endpoint

webhooks:manageIQuote-BusinessReturns 200 Deleted
POST

/v1/webhook_endpoints/{id}/rotate_secret

Rotate the signing secret

Returns the new secret. The old one stops working at once.

webhooks:manageIQuote-BusinessReturns 200 WebhookEndpointWithSecret
POST

/v1/webhook_endpoints/{id}/test

Send a test ping

Delivers a signed ping event now (not stored, not retried) and reports what the endpoint answered.

webhooks:manageIQuote-BusinessReturns 200 WebhookTest
GET

/v1/webhook_endpoints/{id}/deliveries

List recent deliveries

Newest first, up to limit (default 50, at most 100). Not paginated.

webhooks:manageIQuote-BusinessReturns 200 WebhookDeliveryList
POST

/v1/webhook_deliveries/{id}/retry

Retry a delivery now

Queues a finished or failed delivery to be sent again within a minute.

webhooks:manageIQuote-BusinessReturns 200 WebhookDelivery

Webhooks

iQuote POSTs an event to your endpoint when something happens in a business. Create endpoints with the webhooks:manage scope (or in Settings → Developers), up to 10 per business.

curl https://i-quote.app/api/v1/webhook_endpoints \
  -H "Authorization: Bearer $IQUOTE_TOKEN" \
  -H "IQuote-Business: $BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example/webhooks/iquote",
    "events": ["invoice.paid", "invoice.overdue", "payment.received"],
    "description": "Accounting sync"
  }'
# The answer carries "secret": "whsec_..." once. Store it.

Events

Events
Eventdata.objectWhen
quote.sentQuoteA quote was sent to the customer for the first time (its status became sent).
quote.viewedQuoteThe customer opened the quote link.
quote.acceptedQuoteThe customer accepted the quote.
quote.declinedQuoteThe customer declined the quote.
quote.convertedQuoteThe quote became an invoice (converted_invoice_id is set).
invoice.sentInvoiceAn invoice was sent for the first time (its status became sent).
invoice.viewedInvoiceThe customer opened the invoice link.
invoice.paidInvoiceThe invoice is paid in full.
invoice.partially_paidInvoiceA payment left part of the invoice still to pay (a deposit, or a part payment).
invoice.overdueInvoiceThe due date passed with money still owed.
invoice.voidedInvoiceThe invoice was voided.
payment.receivedPaymentA payment succeeded: a card payment, or one recorded by hand.
payment.refundedPaymentMoney was refunded on a payment. The object also carries refunded_now, the amount of this refund.
customer.createdCustomerA customer was added, in the app, by import or through the API.

Subscribe to ["*"] for every event. data.object has the same shape the API returns for that object. Events are recorded only while an enabled endpoint is subscribed to them.

A delivery

HTTP
POST /webhooks/iquote HTTP/1.1
Content-Type: application/json
User-Agent: iQuote-Webhooks/1.0 (+https://i-quote.app/developers)
IQuote-Signature: t=1792152000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
IQuote-Event-Id: 0b8f3c1e-2a4d-4e6f-9a1b-3c5d7e9f1a2b
IQuote-Event-Type: invoice.paid
IQuote-Delivery-Id: 7d2e4f6a-8b0c-4d1e-a2f3-b4c5d6e7f8a9
IQuote-Business: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d

{
  "id": "0b8f3c1e-2a4d-4e6f-9a1b-3c5d7e9f1a2b",
  "object": "event",
  "type": "invoice.paid",
  "created_at": "2026-10-16T09:20:00.000Z",
  "business_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "data": {
    "object": {
      "id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
      "object": "invoice",
      "number": "INV-0042",
      "status": "paid",
      "currency": "USD",
      "total": 2430,
      "amount_paid": 2430,
      "amount_due": 0
    }
  }
}
Delivery headers
HeaderExampleMeaning
IQuote-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>Signature over "<t>.<raw body>" with the endpoint signing secret.
IQuote-Event-IduuidThe event id, the same as the body id. Deduplicate on it.
IQuote-Event-Typeinvoice.paidThe event type, the same as the body type.
IQuote-Delivery-IduuidThis delivery. Retries of one delivery keep the id.
IQuote-BusinessuuidThe business the event belongs to.
User-AgentiQuote-Webhooks/1.0Identifies iQuote deliveries.

Verify the signature

IQuote-Signature is t=<unix seconds>,v1=<signature>. The signature is the hex HMAC-SHA256 of the string "<t>.<raw body>", keyed with the endpoint’s signing secret: the whole whsec_… string. Compute it over the raw bytes you received, before parsing JSON, compare in constant time, and reject timestamps more than 5 minutes from your clock.

import crypto from 'node:crypto';
import express from 'express';

const { IQUOTE_WEBHOOK_SECRET } = process.env; // whsec_..., the whole string is the key

function expectedSignature(secret, timestamp, rawBody) {
  return crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
}

function verifyIQuoteSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = {};
  for (const piece of String(signatureHeader || '').split(',')) {
    const i = piece.indexOf('=');
    if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; // replayed or stale
  const expected = Buffer.from(expectedSignature(secret, timestamp, rawBody), 'hex');
  const received = Buffer.from(parts.v1, 'hex');
  return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}

const app = express();

// The signature covers the exact bytes sent: read the raw body, not parsed JSON.
app.post('/webhooks/iquote', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyIQuoteSignature(rawBody, req.get('IQuote-Signature'), IQUOTE_WEBHOOK_SECRET)) {
    return res.status(400).send('invalid signature');
  }
  const event = JSON.parse(rawBody);
  res.sendStatus(200); // answer within 10 seconds; do slow work after

  // Deliveries can repeat (retries, a manual retry): handle each event id once.
  if (await alreadyProcessed(event.id)) return;
  await handleEvent(event); // event.type, event.business_id, event.data.object
});

Answering, retries and failures

Answer with any 2xx within 10 seconds; do slow work after answering. Anything else, a redirect (redirects are not followed) or no answer in time is a failure.

A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours: 8 attempts in all, then it is marked failed. You can retry any delivery from the API (POST /v1/webhook_deliveries/{id}/retry) or Settings. An endpoint that fails 50 times in a row and has not succeeded for 3 days is disabled; re-enable it with PATCH enabled: true.

The same event can arrive more than once and events can arrive out of order. Deduplicate on the event id, and read created_at or fetch the object when order matters.

Endpoints must be https on a public host (port 443 or 8443). iQuote resolves the host itself and never delivers to private, loopback or link-local addresses. Rotating the secret replaces it at once; POST …/test sends a signed ping event to check your setup.

Building something?

Email us with the request id of anything unexpected and a person replies. support@i-quote.app