Guides

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 - create
  • GET, PATCH, DELETE /v2/expiries/{id} - read, change, delete
  • POST /v2/expiries/{id}/complete and /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.

ParameterValues
statusopen, done, archived, all (default)
typeexact expiry type, e.g. License
updated_sinceISO 8601 time; needs sort=updated_at or -updated_at
sortexpiry_date (default), -expiry_date, updated_at, -updated_at
limit1-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 return 400.
  • List filters: type, updated_since, sort (id default, 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. time uses your org timezone unless you send timezone.
  • GET lists them (soonest first). PATCH with a full reminders list replaces the set (include id to keep one). DELETE removes 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/folders lists flat; filter with parent_folder_id=<id> or root.
  • PATCH renames or moves ("parent_folder_id": null = top level).
  • DELETE on a non-empty folder returns 409 FOLDER_NOT_EMPTY; ?force=true deletes 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 201 with one request per recipient; each gets an email link.
  • GET /v2/collection-requests filters: status, template_id, contact_id, expiry_id (add sort=id to combine them).
  • POST .../{id}/cancel stops a link. POST .../{id}/resend issues a new link ({"skip_email": true} returns it without emailing).
  • Submissions have responses, review_status and files (metadata only - download with v1 getCollectionSubmissionFile). Filter by review_status or request_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

v1v2
AddressesPOST /v1/updateExpiry?id=...PATCH /v2/expiries/{id}
VerbsMost accept any methodListed verbs only; others 405 + Allow
Create201/200, { message, id }201, the record, Location
Delete200 { message }204, empty
Other org's record403 or 404Always 404
ErrorsSeveral shapesAlways { error: { code, message, details, request_id } }
ListsWhole collection or pagesCursor, max 100 per page
Field namesContacts camelCaseStored snake_case (except sendNotifications)
Expiry contactsExpanded by getExpiryIDs
share_passwordReturned by getExpiryNever; use has_share_password
Idempotency-KeySelected endpointsEvery 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

SymptomFix
404 "Unknown API path"Use $BASE/v2/... with BASE=https://api.expiryedge.com (no /v1).
405 METHOD_NOT_ALLOWEDUse 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 completeAdd those checklist ids to checklist_completed.
400 "To combine filters, use sort=id"Add sort=id.
403 PLAN_LIMIT_REACHEDPlan limit used up (details has numbers).
409 EMAIL_EXISTS / GROUP_EXISTS / CONFLICT (folder)Name or email taken. Update the existing record.
409 INVALID_STATECan't cancel a completed request or resend a paused/completed/cancelled one.
404 on /attachments/completeComplete 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, /archive
  • GET, POST /v2/contacts; GET, PATCH, DELETE /v2/contacts/{id}
  • GET, POST, PATCH, DELETE /v2/expiries/{id}/reminders
  • GET, 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, /resend
  • GET /v2/collection-submissions
  • GET, POST /v2/compliance-clients; GET, PATCH, DELETE /v2/compliance-clients/{id}