Try the v2 API (preview)
v2 gives every record its own address and uses normal HTTP verbs, one error shape and cursor pages.
GET /v2/expiries- list,POST /v2/expiries- createGET,PATCH,DELETE /v2/expiries/{id}- read, change, deletePOST /v2/expiries/{id}/completeand/archive
The same pattern covers reminders, attachments, comments, folders, contacts, contact groups, Document Collection and compliance clients - see Step 6 and the Reference. v2 runs the same rules as v1 (plan limits, roles, reminders, workflows).
Preview. Paths and fields may change before GA; changes are announced in the changelog. Everything not listed under Reference is v1 only. For integrations that must not change, use v1.
Before you start
- Uses the same API key or token as v1 - see API keys.
- Reading: any role. Creating, changing, completing, archiving, deleting: Editor or Admin.
Note: BASE has no /v1:
export BASE="https://api.expiryedge.com"
export TOKEN="ee_live_..."Step 1: Create an expiry
Use stored (snake_case) field names. name and expiry_date (YYYY-MM-DD) are required.
curl -s -i -X POST "$BASE/v2/expiries" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: northwind-license-2026" \
-d '{"name":"Northwind Dental - State Dental License","type":"License","expiry_date":"2026-10-15","contacts":["c_8Hk2pQ"]}'HTTP/2 201
location: /v2/expiries/exp_4Tq9sLm2
x-api-version: 2
{ "id": "exp_4Tq9sLm2", "name": "...", "expiry_date": "2026-10-15", "state": "todo", "is_done": false, ... }Step 2: List expiries, one page at a time
curl -s "$BASE/v2/expiries?status=open&sort=expiry_date&limit=50" -H "Authorization: Bearer $TOKEN"{ "data": [ ... ], "next_cursor": "WyJleHBpcnlfZGF0ZSIs...", "has_more": true }While has_more is true, send next_cursor back as cursor with the same sort and filters.
| Parameter | Values |
|---|---|
status | open, done, archived, all (default) |
type | exact expiry type, e.g. License |
updated_since | ISO 8601 time; needs sort=updated_at or -updated_at |
sort | expiry_date (default), -expiry_date, updated_at, -updated_at |
limit | 1-100 (default 50) |
Step 3: Change, complete or archive one expiry
# Change only the fields you send
curl -s -X PATCH "$BASE/v2/expiries/exp_4Tq9sLm2" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"notes":"Renewal form sent to the board."}'
# Mark done (list any required checklist items you completed)
curl -s -X POST "$BASE/v2/expiries/exp_4Tq9sLm2/complete" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"closing_notes":"Renewed for 2 years.","checklist_completed":["upload-certificate"]}'
# Archive
curl -s -X POST "$BASE/v2/expiries/exp_4Tq9sLm2/archive" -H "Authorization: Bearer $TOKEN"Each returns 200 with the expiry. Completing a recurring expiry creates the next one and adds Link: </v2/expiries/...>; rel="next-occurrence".
Step 4: Delete
curl -s -i -X DELETE "$BASE/v2/expiries/exp_4Tq9sLm2" -H "Authorization: Bearer $TOKEN"Returns 204 No Content. Can't be undone.
Step 5: Contacts
/v2/contacts and /v2/contacts/{id} work the same way.
curl -s -X POST "$BASE/v2/contacts" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"first_name":"Priya","last_name":"Shah","email":"priya@northwind.example","sms_phone":"+15550123456","contact_group":"Licensing"}'- Writable:
first_name,last_name,email,sms_phone,whatsapp_phone,contact_type,job_title,timezone,contact_group,is_default,email_opt_in,sms_opt_in,whatsapp_opt_in,sendNotifications. Other fields return400. - List filters:
type,updated_since,sort(iddefault,updated_at,-updated_at),cursor,limit.
Step 6: More resources
All follow the same rules: 201 + Location on create, 204 on delete, 404 for another organization's IDs, lists as { data, next_cursor, has_more }.
Reminders on an expiry
curl -s -X POST "$BASE/v2/expiries/exp_4Tq9sLm2/reminders" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"reminders":[{"amount":30,"time_unit":"day","period":"before","time":"09:00"}]}'- 1-20 reminders per call, scheduled from the expiry's
expiry_date.timeuses your org timezone unless you sendtimezone. GETlists them (soonest first).PATCHwith a fullreminderslist replaces the set (includeidto keep one).DELETEremoves all (204).
Attachments on an expiry
# 1. Start: returns upload_id, upload_url, headers
curl -s -X POST "$BASE/v2/expiries/exp_4Tq9sLm2/attachments" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"file_name":"License 2026.pdf","content_type":"application/pdf","size":482113}'
# 2. PUT the file to upload_url with exactly the returned headers
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" -H "x-goog-content-length-range: 0,482113" --data-binary @"License 2026.pdf"
# 3. Complete: returns 201 { index, name, url }
curl -s -X POST "$BASE/v2/expiries/exp_4Tq9sLm2/attachments/complete" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"upload_id":"up_5Nq2rT"}'GET .../attachments lists files (one page, max 50). DELETE .../attachments?url=<file url> removes one (204).
Comments
curl -s -X POST "$BASE/v2/expiries/exp_4Tq9sLm2/comments" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text":"Board confirmed the renewal fee."}'Returns Location: /v2/comments/{id}. List with GET /v2/expiries/{id}/comments (newest first). PATCH /v2/comments/{id} edits your own; DELETE needs the author or an admin. Comments keep app field names (expiryId, userId, timestamp); text is stored HTML-escaped.
Folders
curl -s -X POST "$BASE/v2/folders" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Dental","parent_folder_id":"fld_2Lq8"}'GET /v2/folderslists flat; filter withparent_folder_id=<id>orroot.PATCHrenames or moves ("parent_folder_id": null= top level).DELETEon a non-empty folder returns409 FOLDER_NOT_EMPTY;?force=truedeletes it and moves its expiries out (not deleted).
Contact groups
curl -s -X POST "$BASE/v2/contact-groups" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Licensing Boards","color":"#1976d2"}'Fields: name (unique, case-insensitive), color (#rrggbb), sort_order. Contacts belong to a group by contact_group = group name, so renaming renames it on contacts and deleting leaves them ungrouped.
Document Collection
# Templates: GET /v2/collection-templates
curl -s -X POST "$BASE/v2/collection-requests" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: w9-priya-2026" \
-d '{"template_id":"tpl_W9x2","contact_ids":["c_8Hk2pQ"],"expiry_id":"exp_4Tq9sLm2","name":"W-9 for 2026","due_date":"2026-10-31"}'
# Poll submissions, oldest first
curl -s "$BASE/v2/collection-submissions?submitted_since=2026-09-01T00:00:00Z&limit=100" -H "Authorization: Bearer $TOKEN"- Create returns
201with one request per recipient; each gets an email link. GET /v2/collection-requestsfilters:status,template_id,contact_id,expiry_id(addsort=idto combine them).POST .../{id}/cancelstops a link.POST .../{id}/resendissues a new link ({"skip_email": true}returns it without emailing).- Submissions have
responses,review_statusandfiles(metadata only - download with v1getCollectionSubmissionFile). Filter byreview_statusorrequest_id.
Compliance clients
curl -s -X POST "$BASE/v2/compliance-clients" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Northwind Dental","email":"office@northwind.example","custom_fields":{"tax_id":"12-3456789"}}'Writable: name, primary_contact_name, email, phone, website, address, industry, status (Active/Inactive), notes, custom_fields. PATCH also takes is_archived. List filters: archived, status. A client with projects can't be deleted (409 CLIENT_HAS_PROJECTS).
Errors - one shape everywhere
{ "error": { "code": "VALIDATION_FAILED", "message": "Some fields are missing or invalid.", "details": { "fields": { "expiry_date": "required, YYYY-MM-DD" } }, "request_id": "9f2c4e1a7b3d" } }Branch on code, show message, quote request_id to support (also in X-Request-Id).
How v2 differs from v1
| v1 | v2 | |
|---|---|---|
| Addresses | POST /v1/updateExpiry?id=... | PATCH /v2/expiries/{id} |
| Verbs | Most accept any method | Listed verbs only; others 405 + Allow |
| Create | 201/200, { message, id } | 201, the record, Location |
| Delete | 200 { message } | 204, empty |
| Other org's record | 403 or 404 | Always 404 |
| Errors | Several shapes | Always { error: { code, message, details, request_id } } |
| Lists | Whole collection or pages | Cursor, max 100 per page |
| Field names | Contacts camelCase | Stored snake_case (except sendNotifications) |
Expiry contacts | Expanded by getExpiry | IDs |
share_password | Returned by getExpiry | Never; use has_share_password |
| Idempotency-Key | Selected endpoints | Every POST |
Versioning and deprecation
- v2 stays in preview until GA is announced in the changelog.
- v1 stays available for at least 12 months after v2 GA, with additive changes only.
- After GA, v2 follows the Versioning and deprecation policy.
Common problems
| Symptom | Fix |
|---|---|
404 "Unknown API path" | Use $BASE/v2/... with BASE=https://api.expiryedge.com (no /v1). |
405 METHOD_NOT_ALLOWED | Use a verb from the Allow header (PATCH to change). |
400 "unknown field" | Use stored names (first_name, not firstName). |
400 "cursor is invalid" | Restart without cursor, same sort. |
400 "updated_since requires sort=updated_at" | Use sort=updated_at. |
400 details.missing_required on complete | Add those checklist ids to checklist_completed. |
400 "To combine filters, use sort=id" | Add sort=id. |
403 PLAN_LIMIT_REACHED | Plan limit used up (details has numbers). |
409 EMAIL_EXISTS / GROUP_EXISTS / CONFLICT (folder) | Name or email taken. Update the existing record. |
409 INVALID_STATE | Can't cancel a completed request or resend a paused/completed/cancelled one. |
404 on /attachments/complete | Complete on the same expiry, as the same user that started it. |
Reference
Full details: openapi.yaml, "v2 (preview)" tag.
GET,POST /v2/expiries;GET,PATCH,DELETE /v2/expiries/{id};POST /v2/expiries/{id}/complete,/archiveGET,POST /v2/contacts;GET,PATCH,DELETE /v2/contacts/{id}GET,POST,PATCH,DELETE /v2/expiries/{id}/remindersGET,POST /v2/expiries/{id}/attachments;POST .../attachments/complete;DELETE .../attachments?url=GET,POST /v2/expiries/{id}/comments;GET,PATCH,DELETE /v2/comments/{id}GET,POST /v2/folders;GET,PATCH,DELETE /v2/folders/{id}GET,POST /v2/contact-groups;GET,PATCH,DELETE /v2/contact-groups/{id}GET /v2/collection-templates,GET /v2/collection-templates/{id}(read-only)GET,POST /v2/collection-requests;GET /v2/collection-requests/{id};POST .../{id}/cancel,/resendGET /v2/collection-submissionsGET,POST /v2/compliance-clients;GET,PATCH,DELETE /v2/compliance-clients/{id}
