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
| Scope | Grants |
|---|---|
contacts:read / contacts:write | Read or upsert contacts (legacy keys with these scopes can also access lists) |
lists:read / lists:write | Read or manage contact lists |
events:read | Read the project event stream |
webhooks:manage | Manage outbound webhook subscriptions |
api-keys:manage | Create additional project-scoped API keys |
test | Marks 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
| Method | Path | Scope |
|---|---|---|
| GET / POST | /api/v1/projects/{project}/contacts | contacts:read / write |
| GET / POST | /api/v1/projects/{project}/lists | lists:read / write |
| GET | /api/v1/projects/{project}/events | events:read |
| GET / POST | /api/v1/projects/{project}/rules | contacts:read / write |
| GET / POST / DELETE | /api/v1/projects/{project}/webhooks | webhooks:manage |
| POST | /api/v1/projects/{project}/api-keys | api-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":"ana@example.com","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":"ana@example.com"}'
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:
| Plan | Requests per minute, per key |
|---|---|
| Starter | 60 |
| Growth | 300 |
| Scale | 1,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.