The Jounce API
Manage your contacts from your own code — subscribers, segments, custom fields, and signed webhooks.
Base URL https://api.jounce.com/v1 · machine spec at openapi.json.
Quickstart
Create a key in Jounce under Account → Integrations → API, then:
curl https://api.jounce.com/v1/me \
-H "Authorization: Bearer jk_live_…"
curl -X POST https://api.jounce.com/v1/subscribers \
-H "Authorization: Bearer jk_live_…" \
-H "Content-Type: application/json" \
-d '{"email":"jane@example.com","first_name":"Jane","segment_ids":["SEGMENT_ID"]}'
Authentication
Two ways in, both first-class: API keys for building on your own account — scripts, your backend, a CRM sync. OAuth2 for an app acting on behalf of Jounce users — they click "Connect Jounce" and approve; no key changes hands.
API keys
- Bearer —
Authorization: Bearer jk_live_…(documented, primary). - HTTP Basic — key as the username, blank password. Works out of the box with generic HTTP clients and existing integration tooling.
- Keys are account-scoped and come in two scopes:
readandfull. A read key gets403 read_only_keyon any write. - Keys are shown once at creation and stored hashed. Revoking a key kills it on the next request.
- Keys are secrets — call the API from your server, never from a browser page.
OAuth2 — apps acting on behalf of users
- Authorization code + refresh. Send the user to
GET https://api.jounce.com/oauth2/authorizewithclient_id,redirect_uri,response_type=code,scope, andstate; they approve on app.jounce.com and return with a single-usecode. Exchange it atPOST https://api.jounce.com/oauth2/token(grant_type=authorization_code, client credentials in the body or HTTP Basic). - Scopes match keys exactly:
readorfull. - Access tokens (
jat_…) last an hour and rideAuthorization: Beareron every /v1 endpoint, same limits as keys. On a 401, refresh (grant_type=refresh_token). - Refresh tokens rotate — every refresh response carries a new
refresh_token; store it. A rotated or revoked token answersinvalid_grant: reconnect. - Users can disconnect your app in their Jounce settings at any moment — treat a 401 as an auth problem, never a retry loop. A connection also ends when the person who approved it leaves the workspace or stops being an Admin or a Manager there, and the webhooks it registered end with it.
- Client credentials are issued by Jounce; there's no self-serve registration yet.
Conventions
- Errors — proper status codes with one envelope:
{"error":{"type","code","message"}}.codeis a stable machine string, e.g.invalid_email,disposable_domain,read_only_key,create_limit_reached. A subscriber write that a rule below refuses answers400, and itscodenames the rule. One that the subscriber kept changing under saves nothing and answers409(subscriber_changed): send it again. One for a subscriber whose erase hasn’t finished saves nothing and answers409(subscriber_erasing): the same request works once the erase finishes. - Rate limits — 120/min per account (batch 20/min), with
X-RateLimit-Limit/-Remaining/-Reseton every response andRetry-Afteron 429. New-subscriber creates cap at 5,000/day, and the day resets at midnight Pacific time — ask support to raise it. - Pagination — cursor-based:
?limit=(max 100) and the response'snext_cursor→?cursor=. Stable on lists of any size; a page never shifts under you. - Timestamps — ISO 8601 UTC. Unknown is
null, never a fake zero.
Endpoints
| Method | Path | |
|---|---|---|
| GET /v1/me | The account + key behind your credentials | |
| GET /v1/subscribers | List subscribers — filter by status, segment_id, or exact email; cursor pagination | |
| POST /v1/subscribers | Create or update a subscriber by email | |
| POST /v1/subscribers/batch | Create or update up to 100 subscribers | |
| GET /v1/subscribers/{id_or_email} | One subscriber | |
| POST /v1/subscribers/{id_or_email}/segments | Add to segments | |
| DELETE /v1/subscribers/{id_or_email}/segments | Remove from segments | |
| POST /v1/subscribers/{id_or_email}/unsubscribe | Unsubscribe from all sends — works even for addresses not in Jounce yet | |
| GET /v1/segments | List segments | |
| POST /v1/segments | Create a segment | |
| GET /v1/segments/{id} | One segment | |
| GET /v1/custom-fields | List the custom-field registry | |
| POST /v1/custom-fields | Register a custom field | |
| GET /v1/forms | List forms | |
| GET /v1/forms/{id} | One form | |
| GET /v1/automations | List automations (read-only) | |
| GET /v1/webhooks | List webhook endpoints | |
| POST /v1/webhooks | Create a webhook endpoint — the secret is returned once | |
| GET /v1/webhooks/{id} | One webhook endpoint | |
| PUT /v1/webhooks/{id} | Update a webhook endpoint (re-enable resets the failure streak) | |
| DELETE /v1/webhooks/{id} | Delete a webhook endpoint |
Subscribers — how writes behave
POST is an upsert by email (safe to retry). Status is one of
subscribed · pending · unsubscribed · bounced · complained.
- An update never changes status. Writing to an unsubscribed contact updates their profile and segments; they stay unsubscribed. The response carries the real
statusand amailableflag — read it. - Suppression wins at birth. Creating an address that previously unsubscribed, bounced, or complained with your account creates it at that status, not
subscribed. An address that can't receive mail at all is refused withundeliverable. - Opt-outs sync in. POST
/unsubscribewith any email — even one Jounce hasn't seen — and it's recorded, so a later create is born unsubscribed. Push your other tools' opt-outs into Jounce freely. double_optin: true(create only) makes the contactpendingand sends your workspace's confirmation email; they join once they click its link.- Attest consent when you have it:
optin_ipandoptin_timestampare recorded in the contact's permission evidence.optin_timestampis when they opted in, so one more than 24 hours ahead of now is refused (invalid_optin_timestamp) and nothing is saved. One up to 24 hours ahead, as a clock or time zone running ahead can send, counts as now, and the evidence keeps the time you sent. - Shared inboxes (
info@,sales@) are accepted with arole_addresswarning. Throwaway domains are refused (disposable_domain). - An old address stays its subscriber's. When a subscriber's email is changed in Jounce, the address they left is still theirs, even after they're deleted: creating it is refused (
previous_address) and nothing is written. Send the address they have now to update them. An unsubscribe by the old address reaches them at the address they have now. - Custom fields ride as
custom_fields— string or boolean values, up to 30 keys per request, under the keysGET /v1/custom-fieldslists. A form's Job Title field isjob-title. - A key spelled like a built-in field (
first name,created_at,E-mail) isn't saved as a custom field. A first or last name fillsfirst_nameorlast_namewhen the request left it empty; anything else is left out. The write succeeds with acustom_field_aliaswarning. A key your account already has is written as before. unverified_fieldslists what one of your forms wrote that no one has changed since, by its path here (first_name,custom_fields.company). Anyone who knows an address can send a form, so wherever it arrives, fill a blank with it; never overwrite.
{
"id": "jane@example.com",
"email": "jane@example.com",
"status": "subscribed",
"source": "api",
"first_name": "Jane",
"last_name": "",
"segments": [{ "id": "SEGMENT_ID", "name": "Customers" }],
"custom_fields": { "company": "Acme" },
"consent_at": "2026-07-22T10:00:00.000Z",
"created_at": "2026-07-22T10:00:00.000Z",
"updated_at": null,
"mailable": true,
"unverified_fields": []
}
Adding a subscriber to a segment can enroll them in any automation that watches that segment — the same engine and safety gates as in-app adds. There is no separate enrolment endpoint; segments are the door.
Webhooks
Register an https endpoint and pick events. Deliveries are signed, retried
(10 tries, waiting longer each time, over about 6 hours), and an endpoint that fails 10 straight
deliveries is switched off — flip disabled back to re-arm it.
| Event | |
|---|---|
| subscriber.created | A subscriber was created — any source (bulk imports send one summary instead) |
| subscriber.updated | Profile or custom fields changed |
| subscriber.subscribed | Their status became subscribed, as when they confirm from your confirmation email (someone added already subscribed sends only subscriber.created) |
| subscriber.unsubscribed | They opted out — sync this into your other tools |
| subscriber.bounced | Their mailbox hard-bounced |
| subscriber.complained | They marked a send as spam |
| subscriber.added_to_segment | Joined a segment (includes segments set at creation) |
| subscriber.removed_from_segment | Left a segment |
| list.imported | A CSV import finished — one summary with counts, not one event per row |
Payload: { event_id, event_name, event_time, webhook_id, changed_by, changed_via, subscriber | list, segment? }.
Bulk imports deliberately do not fire per-row events. A delivery can arrive more than
once; every copy carries the same event_id, so use it to drop a repeat.
Who made the change
Every delivery says who made the change (changed_by) and the door it came through
(changed_via).
| changed_via | changed_by | |
|---|---|---|
| form | public | Your sign-up form. Anyone who knows an address can send it, so it may not be the person. |
| confirm | contact | They pressed Confirm on the page your confirmation email links to. Opening the link alone changes nothing. |
| link | contact | A link action in an email you sent them. A mail scanner that follows links can set one off too. |
| unsubscribe | contact | The unsubscribe link, or the unsubscribe button in their inbox. |
| preferences | contact | They saved their preferences page, from the Manage preferences link in an email you sent them. |
| complaint | contact | They marked an email as spam, and their mailbox provider told Jounce. |
| manual | account | Your team, in Jounce. |
| csv | account | A CSV import. |
| api | account | An API key or a connected app. |
| email_change | account | Your team changed their email address. The contact arrives as subscriber.created at the new address. |
| erase | account | Your team erased their personal data. Deleting a subscriber sends no event, so no delivery carries this. |
| field_delete | account | Your team deleted a custom field from the whole workspace. Jounce clears it from each subscriber in the background and sends no event for that, so no delivery carries this. |
| automation | system | A step in one of your automations. A sign-up form may have started it. |
| bounce | system | Their mailbox rejected an email. |
| domain_check | system | Jounce checked their domain and found it can’t receive mail, or that it can again. |
| operator | system | Jounce’s own team lifted a block on their address, after the person or your team asked. |
publicis unverified. Don't let it overwrite what your other tools already know about someone.- A delivery's
subscriberis the whole record, so a value a form wrote rides later changes too, whoever made them.subscriber.unverified_fieldsnames each one until someone changes it. - Honor every opt-out (
subscriber.unsubscribed,.bounced,.complained), whoever made the change. subscriber.subscribedviaconfirmis the person’s own yes. A form that asks people to confirm sendssubscriber.createdatpending, bypublic: hold it, and add them when this follows. It proves the address, not what the form wrote, sounverified_fieldsstill lists that. Go bychanged_via, since a link action iscontacttoo.- Let a confirm lift only what Jounce told you. Lift an opt-out or a bounce you copied from Jounce, never one the person gave you somewhere else.
- A form that doesn’t ask people to confirm sends
subscriber.createdatsubscribed, bypublic, and nosubscriber.subscribedfollows. contactis the person, or what acts for them: their mailbox provider relays a spam report, and a mail scanner can follow a link.- Both are
nullwhen Jounce has no record of who made the change. - Deliveries can arrive out of order, and one that fails every retry isn’t sent again. Before you act on
subscriber.subscribed, read the subscriber back (GET /v1/subscribers/{id_or_email}) and go by what it says now. - If deliveries stopped (an outage, a disabled webhook), list subscribers by status (
?status=unsubscribed,bounced,complained) to catch the opt-outs you missed.
Verify the signature
Every delivery carries Jounce-Signature: t=<unix>,v1=<hmac> —
HMAC-SHA256 of t + "." + rawBody with your endpoint's secret (shown once at creation):
import crypto from 'node:crypto'
// header: "t=1721650000,v1=6c2e…" rawBody: the EXACT request bytes
export function verifyJounceSignature(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false // 5-min replay window
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''))
}
Migrating from another platform?
The request shapes here follow the field names most email APIs already use
(first_name, custom_fields, segment_ids, double_optin…), and HTTP
Basic auth works as-is — most existing integrations port by swapping the base URL and key. Statuses use Jounce's
names, and pagination is cursor-based.
Changes
What changed in v1, newest first. A change here never breaks a working integration: new keys can appear in any response or delivery, so ignore the ones you don't recognize.
- 2026-10-28 — The
changed_vialist names a new door,preferences, withchanged_bycontact: the subscriber saved their preferences page, from the Manage preferences link in an email you sent them. A Save changes only their name and the segments the page offers, so it arrives assubscriber.updated,subscriber.added_to_segmentorsubscriber.removed_from_segment, never as a change of status. - 2026-10-19 — A workspace can now delete a custom field from the app.
GET /v1/custom-fieldsstops listing it at once, and each subscriber’scustom_fieldsloses it as Jounce clears it in the background, with nosubscriber.updatedevent for those writes. While that runs,POST /v1/custom-fieldswith the same key answers409withcustom_field_deleting: send it again once the delete is done. Nothing else changed shape or meaning. - 2026-10-15 — A
custom_fieldskey spelled like a built-in field, such asfirst name,First_Name,created_atorE-mail, is no longer saved as a custom field beside the real one. A first or last name fillsfirst_nameorlast_namewhen the request left that field empty, and is left out when it didn’t; any other such key is left out. The write still succeeds, and its reply carries acustom_field_aliaswarning. A key your account already has as a custom field (GET /v1/custom-fieldslists them) is written as before, and nothing already saved changes. Send a name asfirst_nameandlast_name. Nothing else changed shape or meaning. - 2026-10-14 — The new-subscriber ceiling resets at midnight Pacific time, as every daily limit in Jounce does. It used to reset at 00:00 UTC. The
create_limit_reachedmessage names the new time, and each account’s count for the day started over once when this changed. Nothing else changed shape or meaning. - 2026-10-12 — A connection ends when the person who approved it leaves the workspace, is removed, or stops being an Admin or a Manager there, as a disconnect ends it: its next call answers
401, its refresh token answersinvalid_grant, and the webhooks it registered are deleted. A disconnect deletes those webhooks too. A call whose connection Jounce couldn’t check just then answers503withconnection_uncheckedand aRetry-After, and a token request answers503withtemporarily_unavailable: send it again, nothing ended. Nothing else changed shape or meaning. - 2026-10-09 — A write to a subscriber whose erase hasn’t finished is refused with
409andsubscriber_erasing, a batch item included, and nothing is written: the same request works once the erase finishes. A read, a segment change or an unsubscribe by id finds no one there and answers404, as it will once the erase is done, and an unsubscribe by their address still records the address and answers for it alone. Nothing else changed shape or meaning. - 2026-10-08 — The
changed_vialist names a new door,erase, withchanged_byaccount: your team erased a subscriber’s personal data. Deleting a subscriber sends no event, so no delivery carries it. Nothing else changed shape or meaning. - 2026-10-08 — A subscriber write whose
optin_timestampis more than 24 hours ahead of now, such as 2062 typed for 2026 or a spreadsheet day number like 45123 read as a year, or is before year 1, is refused withinvalid_optin_timestamp, a batch item included, and nothing is written: no one opts in after the write that records them. It used to store that time as the subscriber’s consent time, or fail the write with a 500 when the year was one a timestamp can’t hold. A time up to 24 hours ahead, as a clock or time zone running ahead can send, now counts as the moment of the write, and the permission evidence keeps the time you sent. Nothing else changed shape or meaning. - 2026-10-08 — A delivery your endpoint fails is now retried for about six hours, as this page always said: Jounce tries again after 1, 2, 4, 8, 16, 32 and 64 minutes, then twice two hours apart, 10 tries in all. It used to give up about 15 minutes after the first failure, after 5 tries. An endpoint is still switched off after 10 deliveries in a row fail every try, or at once when it answers
410. A retry can now reach you after a newer event for the same subscriber, so order byevent_time. Nothing else changed shape or meaning. - 2026-10-02 — A webhook delivery can carry a new
changed_viavalue,operator, withchanged_bysystem: Jounce’s own team lifted a block on the subscriber’s address. It rides the status events you already get: the subscriber is subscribed again once a bounce is cleared, and reads unsubscribed once a spam report is set aside. Nothing else changed shape or meaning. - 2026-09-30 — A change Jounce happened to process twice now reaches your webhook with the same
event_idboth times, so the id drops a repeat however it arose, not only a retry.event_timeis when the change was saved, the same on every copy of one delivery. Nothing else changed shape or meaning. - 2026-09-30 — A new event,
subscriber.subscribed, fires when a subscriber’s status moves tosubscribed, as when someone confirms from your confirmation email after signing up through a form that asks them to, or after signing up again once they had unsubscribed. A confirm’s delivery carries a newchanged_viavalue,confirm, withchanged_bycontact; no other event carries it. A webhook hears the event only once you add it to its events, so no webhook you have gets anything new. Nothing else changed shape or meaning. - 2026-09-30 — A subscriber can be in a segment under its id alone, with no name stored beside it, and Jounce mails them as a member either way.
segments, in a read and in a webhook delivery, now lists such a segment with an emptyname, and shows a segment held twice once. Adding or removing segments keeps every other segment the subscriber is in; it used to drop the ones held by id alone.subscriber.added_to_segmentandsubscriber.removed_from_segmentfire when a subscriber joins or leaves such a segment, and no longer fire when a write only names one they were already in. Match a segment on itsid. Nothing else changed shape or meaning. - 2026-09-29 — A write to a subscriber lands only on the subscriber as it was read. When an email change, an erase or another write lands on them while a request is saving, Jounce reads them again and decides again, so the answer is the one a moment later would get:
previous_addressonce the change has moved them, a new subscriber once they were erased, a404for a segment change. When they keep changing, nothing is saved and the answer is409withsubscriber_changed, a batch item included: send the request again. Nothing else changed shape or meaning. - 2026-09-29 — An unsubscribe by an address one of your subscribers changed away from reaches them at the address they have now, as the unsubscribe link in an email sent before the change does, and your webhooks hear
subscriber.unsubscribedfor them. It used to record only the old address, so they kept getting mail at the new one. The response still describes the address you sent. An unsubscribe that couldn’t be saved in full answers500, and a retry is safe. Nothing else changed shape or meaning. - 2026-09-29 — Creating a subscriber at an address one of your subscribers changed away from is refused with
previous_address, a batch item included, and nothing is written: the address stays theirs. It used to create a new subscriber there, or answer 500 while the change was still finishing. While a change is finishing, a read or a segment change by the old address finds the subscriber who holds it or answers 404. A refused write answers400, as it always has; this page used to say 422.subscriber.updatedno longer fires for a write that leaves everything its delivery shows as it was, like a subscriber you already have sending another of your forms and adding nothing. Every subscriber now carriesunverified_fields, the values one of your forms wrote that no one has changed since, in a read and in a webhook delivery alike.publicMarksjoins the field names Jounce keeps for itself, so a custom field by that name is refused (reserved_field_key). Nothing else changed shape or meaning. - 2026-09-27 — A
POST /v1/custom-fieldswith only alabelnow finds a field you already have by its key or its label, whatever its capitals, spaces, hyphens or underscores, and leaves that field as it is. Before, a label that matched a key renamed the field, and one that matched a label added a second field. To rename a field, send itskeywith the newlabel. A new label that names one of your forms’ fields, such as Job Title, gets the key the form fills in (job-title), so the key no longer follows from the label: the reply now carriesfield, the field the request landed on, and itskeyis the one to send incustom_fields. Nothing else changed shape or meaning. - 2026-09-26 — Webhook deliveries carry
changed_byandchanged_via, which say who made the change.subscriber.updatedno longer fires for a change you can’t see, like Jounce finding a domain can receive mail.lastWrite,confirmationHeldAtandconfirmationHeldReasonjoin the field names Jounce keeps for itself, so a custom field by those names is refused (reserved_field_key). Nothing else changed shape or meaning.
Jounce API v1 · openapi.json · Manage keys in Jounce → Integrations → API