Developer API

OpenContact is API-first. Everything you can do in the dashboard, other tools can do over a project-scoped REST API — so you can build OpenContact into your own product or automate it from your backend.

Authentication

Create an API key on the Developers page. Keys are scoped to a single project and granted specific abilities. Send the key as a bearer token:

Authorization: Bearer YOUR_API_KEY

{project} is the seven-character Project ID shown on the project’s Overview dashboard, for example AB12CD3. It is not the internal database ID.

Endpoints are served under /api/v1 (the unversioned /api paths remain for backward compatibility).

A machine-readable OpenAPI spec is published — import it into Postman/Insomnia or generate a client with an OpenAPI code generator.

Scopes

ScopeGrants
contacts:read / contacts:writeRead or upsert contacts (legacy keys with these scopes can also access lists)
lists:read / lists:writeRead or manage contact lists
events:readRead the project event stream
webhooks:manageManage outbound webhook subscriptions
api-keys:manageCreate additional project-scoped API keys
testMarks a sandbox key — writes are stored (and fire events, so webhooks can be tested) but never sync to real providers or count against plan limits

Endpoints

MethodPathScope
GET / POST/api/v1/projects/{project}/contactscontacts:read / write
GET / POST/api/v1/projects/{project}/listslists:read / write
GET/api/v1/projects/{project}/eventsevents:read
GET / POST/api/v1/projects/{project}/rulescontacts:read / write
GET / POST / DELETE/api/v1/projects/{project}/webhookswebhooks:manage
POST/api/v1/projects/{project}/api-keysapi-keys:manage

Upsert a contact

curl -X POST https://app.opencontact.dev/api/v1/projects/AB12CD3/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","first_name":"Ana","status":"subscribed"}'

Contacts are upserted by normalized email within the project.

Listing & filtering contacts

GET /contacts is paginated and filterable:

curl "https://app.opencontact.dev/api/v1/projects/AB12CD3/contacts?status=unsubscribed&per_page=100" \
  -H "Authorization: Bearer YOUR_API_KEY"

Filters: status, email (exact), search (name/email/company), per_page (max 100), and updated_since (ISO-8601) for incremental sync — pass the timestamp of your last poll and you’ll get only what changed, oldest-first. The response is a standard paginated envelope (data, links, meta).

Idempotent writes

Include an Idempotency-Key header on POST /contacts and a retry with the same key returns the first result without re-running the upsert (or firing its rules/webhooks again):

curl -X POST https://app.opencontact.dev/api/v1/projects/AB12CD3/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: 8f14e45f-…" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'

Read the event stream

curl "https://app.opencontact.dev/api/v1/projects/AB12CD3/events?type=contact.unsubscribed&limit=50" \
  -H "Authorization: Bearer YOUR_API_KEY"

Filter by type, since (ISO-8601), and limit.

Rate limits

Requests are limited per API key, not per account, so one busy integration never starves another. Each plan gets its own allowance:

PlanRequests per minute, per key
Starter60
Growth300
Scale1,000

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. Going over returns 429 with a Retry-After header — wait that many seconds and retry. Unauthenticated requests are limited to 30 per minute per IP address.

Errors

Validation errors return 422, a scope or permission mismatch returns 403, and reaching a monthly sync limit or the API rate limit returns 429.

The full reference — including request bodies and the outbound-webhook contract — is linked from the Developers page.

Still stuck?

Send us your project ID and what you saw. Contact support.