# Replypad API — Agent Reference (llms-full.txt) Flattened from `openapi.yaml` (Replypad API, version 1.0, OpenAPI 3.0.3). Everything an agent needs to call this API correctly is in this one file: conventions, every endpoint, every error code, and end-to-end workflows. Some behavior described here is not stated in the OpenAPI spec; it is documented here because the spec leaves the question open. Contents: 1. Overview 2. Authentication 3. Conventions 4. Endpoints 5. Error catalog 6. Common workflows --- ## 1. Overview Replypad is a help-desk / ticket-management SaaS. The v1 REST API exposes the workspace's operational data: - **Tickets** — plus their comments, labels, CC recipients and followers. - **Accounts** — customer organizations, plus their email domains. - **Contacts** — the people at those organizations, plus their email addresses. - **Assets** — customer hardware and software. - **Items / Charges / Contracts** — billable products and services, logged charges and time entries, and service contracts (contracts are read-only). - **Read-only lookups** — users, teams, labels, stars, mailboxes, KB categories, KB articles, saved responses, and custom-field definitions. - **Token introspection** — what the calling token is and what it may do. **Base URL:** `https://api.replypad.com` **Versioning:** the version is in the path (`/v1/...`). There is no version header and no other released version. **Transport:** JSON only. Send `Content-Type: application/json` on every request with a body, and `Accept: application/json` always. Unknown fields in a request body are rejected (422 `unknown_field`) — the API never silently ignores them. ### Response envelopes | Kind | Shape | |---|---| | List | `{"data": [...], "has_more": bool, "next_cursor": string or null}` | | Single object | `{"data": {...}}` | | Unpaginated collection | `{"data": [...]}` — sub-resources and custom-field definitions; no pagination fields | | Error | `{"error": {"code": "...", "message": "..."}}` | Creation returns **201** with the created object. Deletion returns **204** with an empty body. Writing to a sub-resource collection (`POST`/`PUT` on `/labels`, `/cc-recipients`, `/followers`, `/emails`, `/domains`) returns **200** with the whole collection after the change — not 201. ### Endpoint index | Path | Methods | |---|---| | `/v1/tickets` | GET, POST | | `/v1/tickets/{ticket_id}` | GET, PATCH, DELETE | | `/v1/tickets/{ticket_id}/status` | PUT | | `/v1/tickets/{ticket_id}/comments` | GET, POST | | `/v1/tickets/{ticket_id}/comments/{comment_id}` | GET, PATCH, DELETE | | `/v1/tickets/{ticket_id}/comments/{comment_id}/publish` | PUT | | `/v1/tickets/{ticket_id}/labels` | GET, POST, PUT | | `/v1/tickets/{ticket_id}/labels/{label_id}` | DELETE | | `/v1/tickets/{ticket_id}/cc-recipients` | GET, POST, PUT | | `/v1/tickets/{ticket_id}/cc-recipients/id/{recipient_id}` | DELETE | | `/v1/tickets/{ticket_id}/cc-recipients/email/{email_address}` | DELETE | | `/v1/tickets/{ticket_id}/followers` | GET, POST, PUT | | `/v1/tickets/{ticket_id}/followers/user/{user_id}` | DELETE | | `/v1/tickets/{ticket_id}/followers/email/{email_address}` | DELETE | | `/v1/accounts` | GET, POST | | `/v1/accounts/{account_id}` | GET, PATCH, DELETE | | `/v1/accounts/{account_id}/domains` | GET, POST, PUT | | `/v1/accounts/{account_id}/domains/{domain}` | DELETE | | `/v1/contacts` | GET, POST | | `/v1/contacts/{contact_id}` | GET, PATCH, DELETE | | `/v1/contacts/{contact_id}/emails` | GET, POST, PUT | | `/v1/contacts/{contact_id}/emails/{email}` | DELETE | | `/v1/assets` | GET, POST | | `/v1/assets/{asset_id}` | GET, PATCH, DELETE | | `/v1/items` | GET, POST | | `/v1/items/{item_id}` | GET, PATCH, DELETE | | `/v1/charges` | GET, POST | | `/v1/charges/{charge_id}` | GET, PATCH, DELETE | | `/v1/contracts` | GET | | `/v1/contracts/{contract_id}` | GET | | `/v1/users` | GET | | `/v1/users/{user_id}` | GET | | `/v1/tokens/me` | GET | | `/v1/teams` | GET | | `/v1/teams/{team_id}` | GET | | `/v1/labels` | GET | | `/v1/labels/{label_id}` | GET | | `/v1/stars` | GET | | `/v1/stars/{star_key}` | GET | | `/v1/mailboxes` | GET | | `/v1/mailboxes/{mailbox_id}` | GET | | `/v1/kb-categories` | GET | | `/v1/kb-categories/{category_id}` | GET | | `/v1/kb-articles` | GET | | `/v1/kb-articles/{article_id}` | GET | | `/v1/saved-responses` | GET | | `/v1/saved-responses/{response_id}` | GET | | `/v1/metadata/custom-fields/tickets` | GET | | `/v1/metadata/custom-fields/accounts` | GET | | `/v1/metadata/custom-fields/contacts` | GET | | `/v1/metadata/custom-fields/assets` | GET | | `/v1/metadata/custom-fields/contracts` | GET | | `/v1/metadata/custom-fields/users` | GET | There is no `POST`/`PATCH`/`DELETE` on any path not listed with that method — calling one returns 405 `method_not_allowed` or 404. ### Fixture workspace used throughout this file Every example below is drawn from one consistent workspace. IDs recur across chained calls, so you can read the workflows in section 6 as a single session. | Entity | ID | Value | |---|---|---| | Account (customer) | `acc_fc8e252962bdf56a` | ACME Organization, domain `acme.example` | | Contact | `per_7ca565d07cf38178` | John Doe, `john@acme.example` | | Contact (CC'd) | `per_fcb862e8856489cf` | Bob Smith, `bob@acme.example` | | User — token owner | `per_83771a454a075181` | Jane Doe, `@jane`, `jane@northwind.example` | | User — colleague | `per_da82708cc65f2b1e` | Mark Lee, `@mark`, `mark@northwind.example` | | Team | `tea_1f29804c7ad271bc` | Support | | Mailbox | `mbx_b5c73d081a013a4b` | Support, `support@northwind.example` | | Ticket | `tkt_d44ee8a0fdabaec8` | VPN connection failing for remote employees (number 4022) | | Comment — draft | `cmt_99d88d369db3d6b7` | becomes `cmt_5a3c0d8f2b41e7aa` once published | | Labels | `lbl_9fa361758f05fa48`, `lbl_33f52594685b793e` | VIP, Follow Up | | Asset | `ast_e7b7bbe8aee71135` | HP 250 G10 – 725G5EA (`laptop`) | | Item | `itm_d2c87af1aa36ca8f` | Hard disk replacement (service, hourly 45.00) | | Charge | `crg_dc8faf9ddcbfcdc4` | service charge on the ticket | | Contract | `bct_2f3a5a65fbb0cc95` | Contract with Acme – 2025/27 | | KB article / category | `kba_bf74044c3b65ad94` / `kbc_669d7fd3f95a483f` | Installing Windows 11 / General | | Saved response | `res_5c5585fc630d2d8b` | Install procedure | `northwind.example` is the help desk's own domain (staff users, mailboxes); `acme.example` is the customer's. Both use the reserved `.example` TLD. The examples assume the token is in the environment: ```bash export REPLYPAD_TOKEN=3f9c1e77a4b0d25e8c6a1b93f0472de5c8a91b6df34e70a2c5be18d9f6034a7b ``` --- ## 2. Authentication ### The header Every request carries a bearer token: ``` Authorization: Bearer 3f9c1e77a4b0d25e8c6a1b93f0472de5c8a91b6df34e70a2c5be18d9f6034a7b ``` Treat the token as an opaque string; do not parse it or depend on its length or character set. No other authentication mechanism exists — no cookies, no query parameter, no OAuth flow, no `client_id`/`client_secret`, no signature. A missing or malformed `Authorization` header is rejected with 401 `token_missing`. ### Getting a token Tokens are created **in the Replypad web UI only**. There is no API endpoint that creates, lists, rotates, or revokes tokens. There is exactly one token endpoint, `GET /v1/tokens/me`, and it is read-only: it describes the token you are already holding and never returns the token value. 1. A workspace admin (or the user themselves, per workspace policy) creates an API token in the web application. 2. The token value is displayed **once, at creation**. A lost token cannot be recovered — it can only be replaced with a new one. 3. Store it as a secret and send it in the `Authorization` header. ### What the token carries An API token belongs to a **single user** and acts on that user's behalf. It inherits that user's privileges and access rights **live** — if an admin changes the user's permissions, or deactivates the user, the token's behavior changes on the very next request. There is no separate permission model for tokens beyond the read-only flag. **Access to any resource is subject to the token's permissions. Requests for resources outside those permissions fail with 403.** Call `GET /v1/tokens/me` once at startup to read the permissions granted to the current token, and branch on them rather than discovering them one 403 at a time. Consequences to design for: - A token can see less than the API documents. Access denials on individual tickets are reported as **403 `forbidden`. - A token flagged **read-only** fails every write with **403 `read_only_token`** while reads keep working. Read `read_only_token` from `GET /v1/tokens/me` at startup rather than discovering it on the first write. - Individual **fields** disappear too, not just endpoints — and in two different shapes. See [Field visibility](#field-visibility) in section 3. - `GET /v1/users/{user_id}` returns extra fields (`phone1`..`phone5`, `address1`, `custom_fields`, `web_url`) only when the token's user is an admin. ### Expiry and refresh **There is no refresh flow.** Tokens are not renewed programmatically and there are no refresh tokens. A token either expires on a calendar date or never expires; read `expires_on` from `GET /v1/tokens/me` to find out which. Once past its expiration date, every request fails with 401 `token_expired`. Recovery is manual: a human extends the token or issues a new one in the web UI. An agent that hits `token_expired` should stop and surface the condition — retrying cannot fix it. ### The four 401 codes | Code | Meaning | What to do | |---|---|---| | `token_missing` | Header absent or malformed | Fix the header. Not retryable. | | `token_invalid` | Unknown token, or its workspace cannot be resolved | Check the value; the workspace may also be suspended or past due. Not retryable. | | `token_revoked` | Token deactivated in the UI | Get a new token. Not retryable. | | `token_expired` | Past its expiration date | A human must extend or reissue it. Not retryable. | None of the four is worth retrying. Treat any 401 as fatal for the run. ### Verifying a token `GET /v1/tokens/me` is the probe: cheap, always permitted for a user token, and it answers three startup questions at once — is the token usable, what is my own `user_id`, and what am I allowed to do. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" \ "https://api.replypad.com/v1/tokens/me" | jq . ``` `200` means the token is usable. Any 401 maps to the table above. Read three things out of the response and keep them for the run: - `data.user.id` — your own `user_id`, required when logging service charges and time entries. - `data.read_only_token` — if true, skip every write instead of collecting 403s. - `data.permissions` — see [section 4.11](#411-tokens). Check the relevant flag before a call rather than after it fails. --- ## 3. Conventions ### Object IDs Every object ID is an opaque string beginning with a fixed type prefix and an underscore — e.g. `tkt_d44ee8a0fdabaec8`. Use the prefix to tell types apart if you need to, but treat the rest as opaque: do not parse it, depend on its length or character set, or construct an ID yourself. | Prefix | Type | Prefix | Type | |---|---|---|---| | `acc_` | account | `lbl_` | label | | `per_` | contact **and** user | `kba_` | KB article | | `tkt_` | ticket | `kbc_` | KB category | | `cmt_` | comment | `mbx_` | mailbox | | `ast_` | asset | `res_` | saved response | | `itm_` | item | `tea_` | team | | `crg_` | charge | `bct_` | contract | Two things to get right: - **`per_` is a shared namespace.** Contacts and users are the same ID space. Wherever a contact ID is accepted, a user ID works too — the spec says so explicitly for `contact_id` on assets, for CC recipients, and for the `contact` lookup on ticket create. You cannot tell from the ID alone which one you have; `GET /v1/contacts/{contact_id}` and `GET /v1/users/{user_id}` are the test. - **Stars have no opaque ID.** They are addressed by a `star_key` slug: `red_star`, `green_star`, `blue_star`, `orange_star`, `orchid_star`, `gray_star`, `question_mark`, `truck`, `cloud`, `lightning_bolt`, `phone`, `tool`, `flight`, `pause`, `circle`, `arch`, `do_not_disturb`, `highlight`, `all_inclusive`, `light`, `victory_flag`, `square`, `triangle`. A syntactically invalid ID in a **path** yields 404 (or 400 `invalid_id_format`). An invalid or unresolvable ID inside a **request body** yields 400 or 422. IDs are stable and never reused — with one exception, called out again below: **publishing a draft comment changes its `comment_id`.** ### Dates and times | Field kind | Format | Example | |---|---|---| | Timestamps (`created_on`, `updated_on`, `published_on`, `scheduled_for`, comment `date`) | ISO-8601 UTC, no offset, no fractional seconds | `2026-03-02T09:13:02Z` | | Date-only (charge `date`, `start_date`, `end_date`) | `YYYY-MM-DD` | `2026-03-02` | | Ticket `due_date` | ISO-8601 UTC on read, date **or** timestamp on write | `2026-03-02T00:00:00Z` | | Time-of-day (charge `time.*`) | `HH:MM`, 24-hour | `10:23` | | Money and decimals | string with 2 decimals | `"42.00"` | Timestamps are always UTC; the API never emits a local offset. `due_date` accepts either a date or a full ISO-8601 timestamp on input and is converted to UTC — a date is taken as midnight — and any date at or after `2038-01-01` is rejected. It always reads back as a timestamp, so what you sent as `2026-03-02` returns as `2026-03-02T00:00:00Z`. Money is a **string**, not a number, in responses (`"42.00"`). On input, `price`, `price_rate` and `quantity` accept a JSON number or a numeric string. Parse response money as decimal, never as float, if you intend to do arithmetic. ### Read/write field asymmetries Field names are the same on the way in and the way out. The ticket due date and priority used to differ — `duedate`/`due_date` and `priority_flag`/`priority` — but both are now **`due_date`** and **`priority_flag`** in requests *and* responses. The old names are not accepted as aliases: sending `duedate` or `priority` is a 422 `unknown_field`. Two shape differences remain: | Concept | Write as | Read as | |---|---|---| | Contact primary email | entry in `emails` with `primary_flag: true` | `primary_email` (string, read-only) | | Ticket comment identity | no ID on write | `comment_id`, not `id` — the one sub-object that does not use a plain `id` | Types to get right: - `priority_flag` must be a real JSON boolean in a body. `1`, `0` and `"true"` are 422. As a **query** parameter on `GET /v1/tickets` it is the string `true`/`false` instead, and anything else is 400. - `due_date` accepts a date (`2026-12-31`) or a timestamp (`2026-12-31T17:00:00Z`) on write, and must be before 2038. It always reads back as a **full ISO-8601 UTC timestamp**, never a bare date, so a value you sent as `2026-12-31` returns as `2026-12-31T00:00:00Z`. - `due_date: null` on `PATCH` clears the due date; on `POST` it is ignored. ### Field visibility Privileges hide individual fields, not just whole endpoints. The API does this two different ways, and a client that treats them the same will misread its data: | Field | Needs | Missing value looks like | |---|---|---| | `price` on an item | price visibility | **key absent** | | `price` on a charge's embedded `item` | price visibility | **key absent** | | `billable_flag`, `billed_flag`, `invoice_ref`, `price_rate`, `total_amount` on a charge | price visibility | **`null`**, key present | | `duration_type`, `interval`, `start_date`, `end_date`, `description`, `custom_fields`, `web_url`, `created_on`, `updated_on` on a contract | full billing access rights | **key absent** | | `phone1`..`phone5`, `address1`, `custom_fields`, `web_url` on a user | admin token | **key absent** | So: `"price" in item` is the test for items, but `charge["price_rate"] is None` is the test for charges — and the same charge object mixes both conventions, since `charge.item.price` vanishes while `charge.price_rate` goes null. **`null` on a charge is ambiguous.** `billable_flag` and `billed_flag` are also `null` on time entries, which have no billing state, and `invoice_ref` is `null` on any unbilled charge. Only `price_rate` and `total_amount` are otherwise always populated, so only those two identify "hidden from you" on their own. If you need certainty, read `permissions` from `GET /v1/tokens/me` — although note it carries no flag for price visibility specifically. Writing a hidden field is **refused, not ignored**: `billable_flag` or `price_rate` on a charge without price visibility is 403, and so is filtering `GET /v1/charges` on `billed_flag`/`billable_flag`. ### Array size limits Array-valued fields are capped per collection; exceeding a cap is **422 `validation_failed`**, and the check runs before any item in the array is looked at. The limit on the request body as a whole is per resource - it is stated in each resource's section in part 4. | Collection | Max items | |---|---| | ticket `labels` | 50 | | ticket `cc_recipients` | 20 | | ticket `followers` | 20 | | contact `emails` | 20 | | account `domains` | 20 | | any `custom_fields` map | 50 | The caps apply on the sub-resource endpoints (`POST`/`PUT .../labels` and friends) as well as inside a create or update body. ### Read-only fields that must never be sent back Responses embed summary objects — `account`, `contact`, `asset`, `assignee`, `team`, `mailbox`, `received_on_mailbox`, `item`, `ticket`, `kb_category`, `from`, `web_url`, `number`, `published_on`, `scheduled_for`, `created_on`, `updated_on`. They are output-only. A common failure mode is `GET`, mutate one field, `PUT` the whole object back — that fails with 422 `unknown_field`. **Always send a minimal `PATCH` body containing only the fields you are changing.** ### Pagination All list endpoints use opaque, forward-only cursors. - Page size is **fixed per endpoint and not client-adjustable**: 25 for tickets, 50 for ticket comments and every other list. There is no `limit`, `page` or `page_size` parameter — a cheap probe still costs a full page. - Sort order is fixed per endpoint (documented on each below), and is total and stable. - When `has_more` is `true`, pass `next_cursor` back as `?cursor=`. `has_more` is exact rather than a guess, so `has_more: false` means the walk is finished and `next_cursor` is `null`. **Use a cursor reasonably soon after receiving it.** Pauses between pages are safe; persisting one and resuming days later is not. A cursor marks a position in a live list, not a saved query. A cursor is bound to the endpoint and to the filters of the request that produced it. It is rejected with 400 `invalid_cursor` when it is corrupt, when it is used on a different endpoint, and when a filter sent alongside it disagrees with the request it came from. Filters you *omit* on a paged request are **not** an error — the original request's values continue to apply — but any filter you *do* send must match exactly. `expand` is exempt and may differ from page to page. A complete pagination loop: ```bash CURSOR="" while :; do RESP=$(curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" \ "https://api.replypad.com/v1/tickets?status=open&account_id=acc_fc8e252962bdf56a${CURSOR:+&cursor=$CURSOR}") echo "$RESP" | jq -r '.data[].id' [ "$(echo "$RESP" | jq -r '.has_more')" = "true" ] || break CURSOR=$(echo "$RESP" | jq -r '.next_cursor' | jq -sRr @uri) done ``` Cursors are opaque — do not parse, construct or edit them. **URL-encode them** before putting them in a query string. #### Writes during a walk Each page resumes immediately after the last object of the previous page in the endpoint's sort order. A walk is not a snapshot: each page reflects the data as of the moment it was read. - An object **created** ahead of your position in the sort order is returned on a later page; one created behind it is never seen, because pagination has already moved past that point. A creation never duplicates or skips a neighbouring object. - An object **deleted** ahead of your position is simply absent from the page it would have appeared on. Deletions do not shift the remaining objects into or out of a page. - An object **updated** so that its position in the sort order changes moves with its new value. If it moves ahead of your position it is returned a second time; if it moves behind, it is not returned at all. This applies to the name-sorted lists — renaming an account, contact, item, label and so on. Ticket order never changes, so tickets cannot be affected this way. A full walk therefore guarantees neither exactly-once delivery nor complete coverage while the data is being written to. De-duplicate on `id` and treat a walk as eventually consistent rather than a point-in-time snapshot. ### The `expand` parameter List and detail endpoints accept `expand`, a comma-separated list of related objects or heavy fields to include. Allowed values differ per endpoint; an unknown value is a 400. Requesting nothing is cheaper — expand only what you use. | Endpoint | Allowed `expand` values | |---|---| | `GET /v1/tickets` | `notes`, `comments`, `cc_recipients`, `followers`, `labels`, `custom_fields`, `account`, `contact`, `asset` | | `GET /v1/tickets/{ticket_id}` | `account`, `contact`, `asset` | | `GET /v1/accounts` | `domains`, `banner`, `phones_address`, `custom_fields` | | `GET /v1/accounts/{account_id}` | `contacts` | | `GET /v1/contacts` | `emails`, `phones_address`, `custom_fields`, `account` | | `GET /v1/contacts/{contact_id}` | `account` | | `GET /v1/assets` | `custom_fields`, `desc_notes` | | `GET /v1/charges`, `GET /v1/charges/{charge_id}` | `account`, `ticket` | | `GET /v1/contracts` | `account`, `description`, `custom_fields` | | `GET /v1/contracts/{contract_id}` | `account` | | `GET /v1/kb-articles` | `content` (adds `description` and `body`) | | `GET /v1/saved-responses` | `content` (adds `body`) | Detail endpoints already include their expand-only fields; on detail endpoints `expand` only upgrades an embedded **summary** to the full object (and, for tickets, pulls in `asset`). There is **no cap** on the number of `expand` values in one request: any combination is accepted as long as every value is valid for that endpoint and appears only once. The cost is not free, though. Each expansion is resolved per object in the response, so on a list endpoint the work multiplies by the page size — a full page with several expansions is substantially more expensive than the same page without them, in both response size and latency, and is more likely to hit a request timeout. ### Filtering Multi-value filters are comma-separated in a single parameter: `?status=open,closed`. They are not repeatable (`?status=open&status=closed` is not the documented form). Filter values are the same enum strings the responses use. ### Rate limits Requests are counted against three independent counters — **per token, per workspace, and per client IP**. Exceeding any one of them returns 429. | Counter | per minute | per hour | per day | |---|---|---|---| | Token | 150 | 1500 | 7500 | | Workspace (all its tokens) | 250 | 2500 | 12500 | | Client IP | 400 | 4000 | 20000 | **Write requests count double.** A `POST`/`PATCH`/`PUT`/`DELETE` consumes 2 units, a `GET` consumes 1 — so a pure-write workload exhausts the token's per-minute budget at **75 requests/minute**, not 150. These are defaults and can be overridden per workspace. **Every 429 carries a `Retry-After` header** — whole seconds until the breached window rolls over, or until an error block lifts. Honour it rather than guessing. **No `X-RateLimit-*` headers are sent**, so you still cannot read your remaining budget from a successful response; pace yourself on the table above. A second mechanism guards against runaway agents: after too many consecutive failing calls the token is temporarily blocked and starts receiving 429 `too_many_errors`. While blocked, every call returns 429 — including ones that would otherwise have succeeded — so the block cannot be cleared by continuing to call. The block lifts on its own, and the `Retry-After` header on each 429 tells you exactly how many seconds are left on it. Treat `too_many_errors` as a hard stop: fix the underlying error, then come back when the header says the block is over. **Recommended handling** (guidance, not spec): - Keep concurrency low — 2 in-flight requests is plenty for these limits. - Budget writes at under 70/minute per token. - On `rate_limited`: sleep for `Retry-After` seconds plus a little jitter, then retry. Retry at most 3 times, then escalate. - On `too_many_errors`: **stop.** Do not retry-loop; you are already blocked and each attempt keeps you in the failure path. Read `Retry-After` to see when the block lifts, wait that out, and fix the cause before calling again. - Retry 500 `internal_error` at most twice with exponential backoff (2s, 8s). - On 503 `service_unavailable`: sleep the full `Retry-After`, then resume. It is a maintenance window, not your fault. - Never retry 400, 401, 403, 404, 409 or 422 — they are deterministic. ### Maintenance windows During a planned maintenance window the API is closed: **every endpoint returns 503 `service_unavailable`**, with a `Retry-After` header giving the whole seconds to wait. ```json { "error": { "code": "service_unavailable", "message": "The API is temporarily unavailable for maintenance. Try again shortly." } } ``` Wait out `Retry-After` and resume where you left off. Windows are short, but the header is the only honest estimate; do not substitute a guess of your own. ### Idempotency **The API has no idempotency mechanism.** There is no `Idempotency-Key` header and no request deduplication. A retried `POST` creates a second object. | Operation | Safe to retry blindly? | |---|---| | `GET` | Yes | | `PUT /v1/tickets/{ticket_id}/status` | Yes — setting the current status again succeeds | | `PUT` collection replace (labels, cc-recipients, followers, emails, domains) | Yes — the body is the desired final state | | `PUT .../comments/{comment_id}/publish` | No — the comment ID changes; a second call targets a stale ID | | `PATCH` | Yes in practice (same body produces the same state), but a concurrent writer's change may be clobbered | | `POST` (create) | **No** — duplicates | | `DELETE` | First call 204, second 404 | For `POST` on a timeout or network error, do not blind-retry: **query first** (e.g. `GET /v1/accounts?name=Example+Inc`, or list tickets filtered by `contact_id` and match on subject) to find out whether the object was created, then create only if it was not. The one exception is 503 `service_unavailable`: that call never reached any data, so re-sending the identical `POST` after the maintenance window cannot create a duplicate. Every other failed `POST` still needs the query-first treatment. ### HTTP status map | Status | Meaning | Retry? | |---|---|---| | 200 | OK | — | | 201 | Created (top-level resources only) | — | | 204 | Deleted / removed, empty body | — | | 400 | Malformed request, bad cursor, bad ID format | No — fix the request | | 401 | Credential problem | No — fix credentials | | 403 | Valid token, not permitted (privileges or read-only) | No | | 404 | Not found **or** not visible to this token | No | | 405 | Method not allowed on this path | No | | 409 | Conflict — duplicate value or object still referenced | No — resolve first | | 413 | Request body over the resource's size limit | No — send a smaller body | | 422 | Well-formed but unacceptable data or operation | No — fix the body | | 429 | Rate limited or error-blocked | Yes, with backoff | | 500 | Server error | Twice, with backoff | | 503 | Maintenance window | Yes, after `Retry-After` | --- ## 4. Endpoints ### 4.1 Tickets A ticket is the central object: a customer request with a thread of comments. Its lifecycle is `draft` → `open` → `closed`, with `archived` applied automatically by the system over time. **A request body may not exceed 128 KB.** Anything larger is refused with 413 `payload_too_large` before any validation runs. That cap covers every ticket write, including the comment, label, CC-recipient and follower sub-resources in sections 4.2 and 4.3. Rules that trip up agents: - **Status changes only through `PUT /v1/tickets/{ticket_id}/status`.** Sending `status` to `PATCH /v1/tickets/{ticket_id}` fails with 422 `wrong_endpoint`. The same applies to `comments`, `labels`, `cc_recipients` and `followers` — they may be set at creation, then only through their sub-resource endpoints. - `archived` can be read but never set (422 `not_supported`); moving back to `draft` is 422 `invalid_state`. - Drafts have no `number`; it is assigned when the ticket is published. `published_on` is `null` for every draft — it is only set once the ticket actually goes out. - **A draft can be scheduled, and a scheduled draft belongs to the scheduler.** `scheduled_for` holds the queued time (`null` on anything that is not a scheduled draft). While it is set, `PUT /status`, `PATCH` and `DELETE` all fail with 403 `forbidden` — `Ticket is scheduled for publishing.` There is no API call that cancels the schedule; wait for it to post, or have someone cancel it in the web app. - **A draft ticket's body is a comment.** `PATCH /v1/tickets/{ticket_id}` has no `body` field, so the text of an unpublished ticket is changed by PATCHing that comment — see section 4.2. The other comment calls are closed on a draft ticket. - Comments are **plain text**. Inline images and attachments are not supported anywhere in this API. The examples in this section follow one ticket through its life: created as a draft at 09:13, published at 10:17, starred at 12:02. --- #### GET /v1/tickets List tickets, newest first, 25 per page. **Without a `status` filter, tickets of every status except `archived` are returned**; a customer's unsubmitted draft is never returned. **Query parameters** | Name | Type | Notes | |---|---|---| | `cursor` | string | Pagination cursor from `next_cursor` | | `expand` | csv | `notes`, `comments`, `cc_recipients`, `followers`, `labels`, `custom_fields`, `account`, `contact`, `asset` | | `status` | csv | `draft`, `open`, `closed`, `archived`. Omitted, every status except `archived`. `archived` must be used alone | | `account_id` | string | `acc_...` | | `contact_id` | string | `per_...` | | `asset_id` | string | `ast_...` | | `assignee_id` | string | `per_...` (a user) | | `team_id` | string | `tea_...` | | `mailbox_id` | string | `mbx_...` | | `number` | integer | Exact ticket number | | `priority_flag` | boolean | `true` or `false`, single value only | | `star_key` | string | One star key slug | ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" \ "https://api.replypad.com/v1/tickets?status=open&account_id=acc_fc8e252962bdf56a" | jq . ``` ```json { "data": [ { "id": "tkt_0f63bdcc4364400d", "subject": "Problems with the new printer", "number": 4023, "contact_id": "per_fcb862e8856489cf", "contact": { "id": "per_fcb862e8856489cf", "first_name": "Bob", "last_name": "Smith", "primary_email": "bob@acme.example", "status": "active", "web_url": "https://northwind.replypad.com/people/view/id/per_fcb862e8856489cf" }, "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "asset_id": null, "assignee_id": null, "assignee": null, "team_id": "tea_1f29804c7ad271bc", "team": { "id": "tea_1f29804c7ad271bc", "name": "Support" }, "mailbox_id": "mbx_b5c73d081a013a4b", "mailbox": { "id": "mbx_b5c73d081a013a4b", "name": "Support" }, "received_on_mailbox_id": "mbx_b5c73d081a013a4b", "received_on_mailbox": { "id": "mbx_b5c73d081a013a4b", "name": "Support" }, "priority_flag": false, "star_key": null, "due_date": null, "scheduled_for": null, "published_on": "2026-03-02T11:28:02Z", "status": "open", "web_url": "https://northwind.replypad.com/conversations/view/id/tkt_0f63bdcc4364400d", "created_on": "2026-03-02T11:28:02Z", "updated_on": "2026-03-02T13:36:11Z" }, { "id": "tkt_d44ee8a0fdabaec8", "subject": "VPN connection failing for remote employees", "number": 4022, "contact_id": "per_7ca565d07cf38178", "contact": { "id": "per_7ca565d07cf38178", "first_name": "John", "last_name": "Doe", "primary_email": "john@acme.example", "status": "active", "web_url": "https://northwind.replypad.com/people/view/id/per_7ca565d07cf38178" }, "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "asset_id": null, "assignee_id": "per_da82708cc65f2b1e", "assignee": { "id": "per_da82708cc65f2b1e", "first_name": "Mark", "last_name": "Lee", "nickname": "@mark", "primary_email": "mark@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_da82708cc65f2b1e" }, "team_id": "tea_1f29804c7ad271bc", "team": { "id": "tea_1f29804c7ad271bc", "name": "Support" }, "mailbox_id": "mbx_b5c73d081a013a4b", "mailbox": { "id": "mbx_b5c73d081a013a4b", "name": "Support" }, "received_on_mailbox_id": null, "received_on_mailbox": null, "priority_flag": true, "star_key": null, "due_date": null, "scheduled_for": null, "published_on": "2026-03-02T10:17:44Z", "status": "open", "web_url": "https://northwind.replypad.com/conversations/view/id/tkt_d44ee8a0fdabaec8", "created_on": "2026-03-02T09:13:02Z", "updated_on": "2026-03-02T10:17:44Z" } ], "has_more": false, "next_cursor": null } ``` Note what is **absent** from list items: `comments`, `notes`, `labels`, `cc_recipients`, `followers`, `custom_fields`. Add them with `expand`. --- #### POST /v1/tickets Create a ticket. Requires `subject` and/or exactly one entry in `comments`. **Body fields** | Field | Type | Notes | |---|---|---| | `subject` | string ≤255 | Required unless `comments` is given | | `comments` | array, max 1 item | `{body, status, type}` — see `POST .../comments` below | | `contact_id` | string | Mutually exclusive with `contact` | | `contact` | object | `{primary_email, first_name, last_name}` — finds a contact by email, creates one if absent | | `account_id` | string | Mutually exclusive with `account` | | `account` | object | `{name}` — finds an account by name, creates one if absent | | `asset_id` | string | | | `status` | enum | `draft`, `open`, `closed`. Default `open`. `archived` rejected | | `assignee_id` | string | A user ID. Defaults to the token's user when omitted | | `team_id` | string | Defaults to the workspace's default team | | `mailbox_id` | string | Defaults to the default mailbox | | `labels` | array | `[{id}]` or `[{name}]` — must reference existing active labels | | `cc_recipients` | array | `[{id}]` or `[{email_address, first_name, last_name}]` | | `followers` | array | `[{id}]` or `[{email_address}]` — must be existing active users | | `priority_flag` | boolean | Default `false` | | `star_key` | enum | Star slug | | `notes` | string ≤4000 | | | `due_date` | string | `YYYY-MM-DD` or ISO-8601 timestamp, before 2038 | | `custom_fields` | object | `{key: "string value"}` | Passing both `contact_id` and `contact` (or both `account_id` and `account`) is rejected. Unknown fields are rejected with 422 `unknown_field`. ```bash curl -s -X POST "https://api.replypad.com/v1/tickets" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "subject": "VPN connection failing for remote employees", "comments": [ { "body": "Several employees reported that they cannot establish a VPN connection when working remotely. The connection attempt fails with an authentication timeout error.", "type": "user_public_comment" } ], "contact_id": "per_7ca565d07cf38178", "account_id": "acc_fc8e252962bdf56a", "assignee_id": "per_da82708cc65f2b1e", "team_id": "tea_1f29804c7ad271bc", "priority_flag": true, "status": "draft", "custom_fields": { "extra_note": "Happened second time" } }' | jq . ``` `201 Created`: ```json { "data": { "id": "tkt_d44ee8a0fdabaec8", "subject": "VPN connection failing for remote employees", "comments": [ { "comment_id": "cmt_9da8a50b6eb8204f", "body": "Several employees reported that they cannot establish a VPN connection when working remotely. The connection attempt fails with an authentication timeout error.", "status": "published", "type": "user_public_comment", "source": "api", "from_id": "per_83771a454a075181", "from": { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "primary_email": "jane@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_83771a454a075181" }, "date": "2026-03-02T09:13:02Z", "resolution_flag": false, "scheduled_for": null } ], "notes": null, "contact_id": "per_7ca565d07cf38178", "contact": { "id": "per_7ca565d07cf38178", "first_name": "John", "last_name": "Doe", "primary_email": "john@acme.example", "status": "active", "web_url": "https://northwind.replypad.com/people/view/id/per_7ca565d07cf38178" }, "cc_recipients": null, "followers": null, "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "asset_id": null, "assignee_id": "per_da82708cc65f2b1e", "assignee": { "id": "per_da82708cc65f2b1e", "first_name": "Mark", "last_name": "Lee", "nickname": "@mark", "primary_email": "mark@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_da82708cc65f2b1e" }, "team_id": "tea_1f29804c7ad271bc", "team": { "id": "tea_1f29804c7ad271bc", "name": "Support" }, "mailbox_id": "mbx_b5c73d081a013a4b", "mailbox": { "id": "mbx_b5c73d081a013a4b", "name": "Support" }, "received_on_mailbox_id": null, "received_on_mailbox": null, "labels": null, "priority_flag": true, "star_key": null, "due_date": null, "scheduled_for": null, "published_on": null, "status": "draft", "custom_fields": { "severity_level": null, "extra_note": "Happened second time" }, "web_url": "https://northwind.replypad.com/conversations/addnew/draftid/tkt_d44ee8a0fdabaec8", "created_on": "2026-03-02T09:13:02Z", "updated_on": "2026-03-02T09:13:02Z" } } ``` Note: the comment published at creation is authored by the **token's user** (`per_83771a454a075181`, Jane), not by the ticket's contact. `source` is `api`. The draft has no `number` and its `web_url` points at the draft editor. --- #### GET /v1/tickets/{ticket_id} Retrieve one ticket. Detail responses always include `comments`, `notes`, `labels`, `cc_recipients`, `followers` and `custom_fields`. **Query:** `expand` — `account`, `contact`, `asset` (upgrades the embedded summary to the full object; `asset` is only included when expanded). ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" \ "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8" | jq . ``` ```json { "data": { "id": "tkt_d44ee8a0fdabaec8", "subject": "VPN connection failing for remote employees", "comments": [ { "comment_id": "cmt_9da8a50b6eb8204f", "body": "Several employees reported that they cannot establish a VPN connection when working remotely. The connection attempt fails with an authentication timeout error.", "status": "published", "type": "user_public_comment", "source": "api", "from_id": "per_83771a454a075181", "from": { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "primary_email": "jane@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_83771a454a075181" }, "date": "2026-03-02T09:13:02Z", "resolution_flag": false, "scheduled_for": null } ], "notes": null, "number": 4022, "contact_id": "per_7ca565d07cf38178", "contact": { "id": "per_7ca565d07cf38178", "first_name": "John", "last_name": "Doe", "primary_email": "john@acme.example", "status": "active", "web_url": "https://northwind.replypad.com/people/view/id/per_7ca565d07cf38178" }, "cc_recipients": [ { "id": "per_fcb862e8856489cf", "first_name": "Bob", "last_name": "Smith", "email_address": "bob@acme.example" } ], "followers": [ { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "nickname": "@jane", "email_address": "jane@northwind.example" } ], "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "asset_id": null, "assignee_id": "per_da82708cc65f2b1e", "assignee": { "id": "per_da82708cc65f2b1e", "first_name": "Mark", "last_name": "Lee", "nickname": "@mark", "primary_email": "mark@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_da82708cc65f2b1e" }, "team_id": "tea_1f29804c7ad271bc", "team": { "id": "tea_1f29804c7ad271bc", "name": "Support" }, "mailbox_id": "mbx_b5c73d081a013a4b", "mailbox": { "id": "mbx_b5c73d081a013a4b", "name": "Support" }, "received_on_mailbox_id": null, "received_on_mailbox": null, "labels": [ { "id": "lbl_9fa361758f05fa48", "name": "VIP" } ], "priority_flag": true, "star_key": null, "due_date": null, "scheduled_for": null, "published_on": "2026-03-02T10:17:44Z", "status": "open", "custom_fields": { "severity_level": null, "extra_note": "Happened second time" }, "web_url": "https://northwind.replypad.com/conversations/view/id/tkt_d44ee8a0fdabaec8", "created_on": "2026-03-02T09:13:02Z", "updated_on": "2026-03-02T10:17:44Z" } } ``` --- #### PATCH /v1/tickets/{ticket_id} Partial update. Accepts `subject`, `contact_id` / `contact`, `account_id` / `account`, `asset_id`, `assignee_id`, `team_id`, `mailbox_id`, `priority_flag`, `star_key`, `notes`, `due_date`, `custom_fields`. The nullable ones (`asset_id`, `notes`, `due_date`) accept `null` to clear. `status`, `comments`, `labels`, `cc_recipients`, `followers` → 422 `wrong_endpoint`. The `contact` / `account` lookup objects work only when the ticket does not already have one set. A draft with `scheduled_for` set cannot be updated at all → 403 `forbidden`. ```bash curl -s -X PATCH "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"star_key": "red_star"}' | jq . ``` `200 OK` — the full ticket object, same shape as `GET /v1/tickets/{ticket_id}` above, with: ```json { "data": { "star_key": "red_star", "updated_on": "2026-03-02T12:02:11Z" } } ``` --- #### DELETE /v1/tickets/{ticket_id} Move the ticket to the trash. Returns `204` with no body. A draft with `scheduled_for` set cannot be deleted → 403 `forbidden`. ```bash curl -s -X DELETE "https://api.replypad.com/v1/tickets/tkt_0f63bdcc4364400d" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" -o /dev/null -w '%{http_code}\n' ``` --- #### PUT /v1/tickets/{ticket_id}/status Move a ticket to `open` or `closed`. Publishing a draft this way uses the content already stored on the draft and assigns the ticket `number`. **Idempotent** — setting the current status again succeeds. **Body:** `{"status": "open" | "closed"}` (required). Rejections: `draft` → 422 `invalid_state`; `archived` → 422 `not_supported`; a draft already scheduled for publishing → 403 `forbidden`, `Ticket is scheduled for publishing.` (not retryable — the scheduler owns it until it goes out). ```bash curl -s -X PUT "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/status" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"status": "open"}' | jq . ``` `200 OK` — the full ticket object, with: ```json { "data": { "number": 4022, "status": "open", "scheduled_for": null, "published_on": "2026-03-02T10:17:44Z", "web_url": "https://northwind.replypad.com/conversations/view/id/tkt_d44ee8a0fdabaec8", "updated_on": "2026-03-02T10:17:44Z" } } ``` --- ### 4.2 Ticket comments Comment types: `user_public_comment` (a reply the customer sees), `user_internal_note` (staff-only), and `customer_comment` (created by customers via portal or email — **not creatable through the API**). `source` is read-only and is one of `web_portal`, `email`, `trigger`, `api`. **The one rule to remember: publishing a draft comment gives it a new `comment_id`.** Discard the draft ID after publishing and use the ID from the publish response. A draft comment can also be scheduled. `scheduled_for` holds the queued time and is `null` on published comments and on unscheduled drafts. While it is set the comment is the scheduler's: `PATCH`, `DELETE` and `PUT .../publish` all fail with 403 `forbidden` — `Comment is scheduled for publishing.` **Comments on a draft (unpublished) ticket.** A draft ticket's body is itself a draft comment, so it is listed by `GET .../comments` and it is the one comment you may `PATCH` — the only way to change the text of an unpublished ticket, since `PATCH /v1/tickets/{ticket_id}` has no `body` field. The rest of the comment calls belong to the ticket, not to the thread: | Call on a draft ticket | Result | |---|---| | `PATCH .../comments/{comment_id}` | Allowed. `body` and `from_id` only — `type` is 422 `invalid_state` | | `POST .../comments` | 422 `wrong_endpoint` — publish the ticket first with `PUT /v1/tickets/{ticket_id}/status` | | `PUT .../comments/{comment_id}/publish` | 422 `wrong_endpoint` — publishing that comment means publishing the ticket: `PUT /v1/tickets/{ticket_id}/status` | | `DELETE .../comments/{comment_id}` | 422 `wrong_endpoint` — discard the ticket with `DELETE /v1/tickets/{ticket_id}` | If the draft ticket is scheduled for publishing, the `PATCH` is closed too — 403 `forbidden`, `Ticket is scheduled for publishing.` --- #### GET /v1/tickets/{ticket_id}/comments List a ticket's comments, 50 per page. **Without a `status` filter both `published` and `draft` comments are returned.** **Query:** `cursor`; `status` (csv of `published`, `draft`; default both); `type` (csv of `user_public_comment`, `customer_comment`, `user_internal_note`; default all types). ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" \ "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments?status=published,draft" | jq . ``` ```json { "data": [ { "comment_id": "cmt_9da8a50b6eb8204f", "body": "Several employees reported that they cannot establish a VPN connection when working remotely. The connection attempt fails with an authentication timeout error.", "status": "published", "type": "user_public_comment", "source": "api", "from_id": "per_83771a454a075181", "from": { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "primary_email": "jane@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_83771a454a075181" }, "date": "2026-03-02T09:13:02Z", "resolution_flag": false, "scheduled_for": null }, { "comment_id": "cmt_99d88d369db3d6b7", "body": "Hi John, we are looking into the VPN authentication timeouts now.", "status": "draft", "type": "user_public_comment", "source": "api", "from_id": "per_83771a454a075181", "from": { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "primary_email": "jane@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_83771a454a075181" }, "date": "2026-03-02T11:04:37Z", "resolution_flag": false, "scheduled_for": null } ], "has_more": false, "next_cursor": null } ``` For drafts, `date` is the last update time; for published comments it is the publication time. `scheduled_for` is separate: it is the time a scheduled draft is queued to go out, and `null` everywhere else. --- #### POST /v1/tickets/{ticket_id}/comments Add a comment. It is always published as the token's user. **Body:** `body` (required, ≤32767 chars, plain text); `status` (`published` | `draft`, default `published`); `type` (`user_public_comment` | `user_internal_note`, default `user_public_comment`). Creating with `status: "published"` sends the reply immediately. Use `draft` when you want to review before it goes out. Not available on a draft (unpublished) ticket — 422 `wrong_endpoint`. ```bash curl -s -X POST "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "body": "Hi John, we are looking into the VPN authentication timeouts now.", "status": "draft", "type": "user_public_comment" }' | jq . ``` `201 Created`: ```json { "data": { "comment_id": "cmt_99d88d369db3d6b7", "body": "Hi John, we are looking into the VPN authentication timeouts now.", "status": "draft", "type": "user_public_comment", "source": "api", "from_id": "per_83771a454a075181", "from": { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "primary_email": "jane@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_83771a454a075181" }, "date": "2026-03-02T11:04:37Z", "resolution_flag": false, "scheduled_for": null } } ``` --- #### GET /v1/tickets/{ticket_id}/comments/{comment_id} Retrieve one comment. Same object shape as above. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" \ "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments/cmt_99d88d369db3d6b7" | jq . ``` --- #### PATCH /v1/tickets/{ticket_id}/comments/{comment_id} Update a comment. **Only `draft` comments can be updated** — anything else is 422 `invalid_state`. Accepts `body`, `type` and `from_id`; `status` is 422 `wrong_endpoint` — publish through `PUT .../publish`. On a **draft (unpublished) ticket** this is how the ticket's body is edited, and there only `body` and `from_id` are accepted — `type` is 422 `invalid_state`. ```bash curl -s -X PATCH "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments/cmt_99d88d369db3d6b7" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"body": "Hi John, the VPN authentication timeouts are caused by an expired RADIUS certificate. We are replacing it now."}' | jq . ``` `200 OK` — the comment, with the new `body` and `date` `2026-03-02T11:05:48Z`; `comment_id` is unchanged (it only changes on publish). --- #### DELETE /v1/tickets/{ticket_id}/comments/{comment_id} Discard a draft comment. Only `draft` comments can be deleted. Returns `204`. Not available on a draft (unpublished) ticket — 422 `wrong_endpoint`; that comment is the ticket's body, so discard the ticket with `DELETE /v1/tickets/{ticket_id}`. --- #### PUT /v1/tickets/{ticket_id}/comments/{comment_id}/publish Publish a draft comment. **No request body.** **This assigns a new `comment_id`.** The draft ID stops resolving; use the ID from this response from now on. Not available on a draft (unpublished) ticket — 422 `wrong_endpoint`; publishing a draft ticket is `PUT /v1/tickets/{ticket_id}/status`. ```bash curl -s -X PUT "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments/cmt_99d88d369db3d6b7/publish" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Accept: application/json" | jq . ``` ```json { "data": { "comment_id": "cmt_5a3c0d8f2b41e7aa", "body": "Hi John, the VPN authentication timeouts are caused by an expired RADIUS certificate. We are replacing it now.", "status": "published", "type": "user_public_comment", "source": "api", "from_id": "per_83771a454a075181", "from": { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "primary_email": "jane@northwind.example", "web_url": "https://northwind.replypad.com/people/view/id/per_83771a454a075181" }, "date": "2026-03-02T11:06:02Z", "resolution_flag": false, "scheduled_for": null } } ``` --- ### 4.3 Ticket labels, CC recipients, followers All three follow the same pattern: `GET` reads the collection, `POST` adds to it, `PUT` replaces it wholesale (an empty array clears it), and `DELETE` removes one member. `POST` and `PUT` take a **bare JSON array** as the body — not an object wrapping one — and all three return `200` with the full collection under `data`. --- #### GET / POST / PUT /v1/tickets/{ticket_id}/labels Labels must already exist and be active. Reference them by `id` or by exact `name`; **an unknown name is rejected, not created.** There is no label-creation endpoint in this API. ```bash # list curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/labels" | jq . # add (body is an array) curl -s -X POST "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/labels" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"name": "VIP"}, {"id": "lbl_33f52594685b793e"}]' | jq . # replace the whole set curl -s -X PUT "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/labels" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"id": "lbl_9fa361758f05fa48"}]' | jq . ``` `200 OK` (all three): ```json { "data": [ { "id": "lbl_9fa361758f05fa48", "name": "VIP" }, { "id": "lbl_33f52594685b793e", "name": "Follow Up" } ] } ``` #### DELETE /v1/tickets/{ticket_id}/labels/{label_id} Remove one label. Returns `204`. ```bash curl -s -X DELETE "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/labels/lbl_33f52594685b793e" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" -o /dev/null -w '%{http_code}\n' ``` --- #### GET / POST / PUT /v1/tickets/{ticket_id}/cc-recipients People copied on every message sent for the ticket. Each array item has **exactly one** of `id` (an existing contact or user) or `email_address`. - With `id`: that contact/user's primary email is used; `first_name`/ `last_name` are ignored. - With `email_address`: an existing contact or user with that address is used; if none exists, **a new contact is created** with the given `first_name` / `last_name`. ```bash curl -s -X POST "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/cc-recipients" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"id": "per_fcb862e8856489cf"}, {"email_address": "tom@acme.example", "first_name": "Tom"}]' | jq . ``` ```json { "data": [ { "id": "per_fcb862e8856489cf", "first_name": "Bob", "last_name": "Smith", "email_address": "bob@acme.example" }, { "id": "per_648fcb862e8859cf", "first_name": "Tom", "last_name": null, "email_address": "tom@acme.example" } ] } ``` #### DELETE /v1/tickets/{ticket_id}/cc-recipients/id/{recipient_id} #### DELETE /v1/tickets/{ticket_id}/cc-recipients/email/{email_address} Remove one recipient, by contact/user ID or by URL-encoded email. Both return `204`. ```bash curl -s -X DELETE "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/cc-recipients/id/per_fcb862e8856489cf" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" -o /dev/null -w '%{http_code}\n' curl -s -X DELETE "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/cc-recipients/email/tom%40acme.example" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" -o /dev/null -w '%{http_code}\n' ``` --- #### GET / POST / PUT /v1/tickets/{ticket_id}/followers Staff users watching the ticket without being its assignee. Each item has exactly one of `id` or `email_address`, and unlike CC recipients the address **must match an existing active user** — no contact is created (422 otherwise). ```bash curl -s -X POST "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/followers" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"id": "per_83771a454a075181"}, {"email_address": "mark@northwind.example"}]' | jq . ``` ```json { "data": [ { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "nickname": "@jane", "email_address": "jane@northwind.example" }, { "id": "per_da82708cc65f2b1e", "first_name": "Mark", "last_name": "Lee", "nickname": "@mark", "email_address": "mark@northwind.example" } ] } ``` #### DELETE /v1/tickets/{ticket_id}/followers/user/{user_id} #### DELETE /v1/tickets/{ticket_id}/followers/email/{email_address} Remove one follower, by user ID or URL-encoded email. Both return `204`. --- ### 4.4 Accounts Customer organizations. Account **names are unique per workspace** — a duplicate is 409 `already_exists`. Email domains attached to an account cause incoming mail from those domains to be routed to it automatically. **A request body may not exceed 16 KB.** Anything larger is refused with 413 `payload_too_large` before any validation runs. --- #### GET /v1/accounts List accounts, sorted by name ascending, 50 per page. **Query:** `cursor`; `expand` (`domains`, `banner`, `phones_address`, `custom_fields`); `search` (fuzzy match); `name` (exact match — **overrides `search` and `status`, which are ignored when it is present**); `status` (csv of `active`, `inactive`). ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/accounts?search=ACME&expand=domains" | jq . ``` ```json { "data": [ { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "contact_access_rights": "restricted", "domains": [ { "domain_name": "acme.example" } ], "created_on": "2025-11-21T14:07:12Z", "updated_on": "2026-01-08T09:30:00Z" } ], "has_more": false, "next_cursor": null } ``` Use `?name=` for an exact-match existence check before creating — this is the recommended way to make account creation safe to retry. --- #### POST /v1/accounts **Body:** `name` (required, ≤150, unique); `status` (`active` | `inactive`, default `active`); `contact_access_rights` (`restricted` | `all`, default `restricted`); `banner` (`{notice ≤4000, level}` where level is `red`, `orange`, `green`, `light_gray`, `dark_gray`); `phone1`..`phone5` (`{number ≤25, description ≤50}`); `address1` (`{addressline1..3, city, state, zip, country}`); `custom_fields`; `domains` (array of `{domain_name}` — **accepted only at creation**, afterwards use the `/domains` endpoints). ```bash curl -s -X POST "https://api.replypad.com/v1/accounts" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "name": "ACME Organization", "domains": [{"domain_name": "acme.example"}], "phone1": {"number": "(555) 123-4567", "description": "Work phone"}, "address1": { "addressline1": "123 Main St", "addressline2": "Apt 4B", "city": "Springfield", "state": "IL", "zip": "62704", "country": "United States" }, "custom_fields": {"industry": "Tech"} }' | jq . ``` `201 Created`: ```json { "data": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "contact_access_rights": "restricted", "domains": [ { "domain_name": "acme.example" } ], "banner": null, "phone1": { "number": "(555) 123-4567", "description": "Work phone" }, "phone2": null, "phone3": null, "phone4": null, "phone5": null, "address1": { "addressline1": "123 Main St", "addressline2": "Apt 4B", "addressline3": null, "city": "Springfield", "state": "IL", "zip": "62704", "country": "United States" }, "custom_fields": { "industry": "Tech", "legal_structure": null }, "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a", "created_on": "2025-11-21T14:07:12Z", "updated_on": "2025-11-21T14:07:12Z" } } ``` `custom_fields` echoes **every** field defined for the resource type, with `null` for the ones you did not set — not just the ones you sent. --- #### GET /v1/accounts/{account_id} Retrieve one account. **Query:** `expand=contacts` adds a `contacts` array of contact summaries. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/accounts/acc_fc8e252962bdf56a" | jq . ``` Same shape as the create response, plus a populated `banner` when set: ```json { "data": { "banner": { "notice": "The payment for last month was received two weeks later than expected.", "level": "red" } } } ``` --- #### PATCH /v1/accounts/{account_id} Partial update: `name`, `status`, `contact_access_rights`, `banner` (nullable), `phone1`..`phone5`, `address1`, `custom_fields`. `domains` → 422; use the `/domains` endpoints. ```bash curl -s -X PATCH "https://api.replypad.com/v1/accounts/acc_fc8e252962bdf56a" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"status": "inactive"}' | jq . ``` `200 OK` — the full account, with `"status": "inactive"` and a new `updated_on`. Note: **deactivating an account also makes its contacts inactive.** --- #### DELETE /v1/accounts/{account_id} `204`. Fails with 409 `in_use` if other objects still reference it. --- #### GET / POST / PUT /v1/accounts/{account_id}/domains `GET` lists; `POST` adds **one** domain (body is a single object, not an array); `PUT` replaces the list with an array (empty array clears it). All return `200` with `{"data": [...]}`. ```bash # add one curl -s -X POST "https://api.replypad.com/v1/accounts/acc_fc8e252962bdf56a/domains" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"domain_name": "acme-corp.example"}' | jq . # replace all curl -s -X PUT "https://api.replypad.com/v1/accounts/acc_fc8e252962bdf56a/domains" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"domain_name": "acme.example"}, {"domain_name": "acme-corp.example"}]' | jq . ``` ```json { "data": [ { "domain_name": "acme.example" }, { "domain_name": "acme-corp.example" } ] } ``` A domain already claimed by another account is 409 `already_exists`. #### DELETE /v1/accounts/{account_id}/domains/{domain} URL-encoded domain name. Returns `204`. ```bash curl -s -X DELETE "https://api.replypad.com/v1/accounts/acc_fc8e252962bdf56a/domains/acme-corp.example" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" -o /dev/null -w '%{http_code}\n' ``` --- ### 4.5 Contacts The people at customer accounts. Remember that contacts and users share the `per_` namespace, and that **email addresses are unique across all contacts and users** in the workspace. **A request body may not exceed 16 KB.** Anything larger is refused with 413 `payload_too_large` before any validation runs. --- #### GET /v1/contacts Sorted by first name, last name, then primary email; 50 per page. **Query:** `cursor`; `expand` (`emails`, `phones_address`, `custom_fields`, `account`); `search`; `account_id`; `status` (csv of `active`, `inactive`). ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/contacts?search=john%40acme.example" | jq . ``` ```json { "data": [ { "id": "per_7ca565d07cf38178", "first_name": "John", "last_name": "Doe", "primary_email": "john@acme.example", "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "status": "active", "contact_access_rights": "restricted", "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } ], "has_more": false, "next_cursor": null } ``` This search-by-email call is the standard way to resolve an inbound email address to a contact ID before creating a ticket. --- #### POST /v1/contacts At least one of `first_name`, `last_name` or `emails` is required. **Body:** `first_name` / `last_name` (≤50); `emails` (array of `{email_address, primary_flag}` — **only at creation**; if given, exactly one entry must be primary); `account_id`; `status` (default `active`); `contact_access_rights` (`restricted` | `all` | `by_account`, default `by_account`); `phone1`..`phone5`; `address1`; `custom_fields`. ```bash curl -s -X POST "https://api.replypad.com/v1/contacts" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "first_name": "John", "last_name": "Doe", "account_id": "acc_fc8e252962bdf56a", "emails": [{"email_address": "john@acme.example", "primary_flag": true}], "phone1": {"number": "(555) 123-4567", "description": "Work phone"} }' | jq . ``` `201 Created`: ```json { "data": { "id": "per_7ca565d07cf38178", "first_name": "John", "last_name": "Doe", "primary_email": "john@acme.example", "emails": [ { "email_address": "john@acme.example", "primary_flag": true } ], "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "status": "active", "contact_access_rights": "by_account", "phone1": { "number": "(555) 123-4567", "description": "Work phone" }, "phone2": null, "phone3": null, "phone4": null, "phone5": null, "address1": null, "custom_fields": { "vip_status": null, "vip_plan_level": null }, "web_url": "https://northwind.replypad.com/people/view/id/per_7ca565d07cf38178", "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } } ``` An email address already used by another contact or user is 409 `already_exists`. --- #### GET /v1/contacts/{contact_id} **Query:** `expand=account` replaces the account summary with the full account. Response shape as above. #### PATCH /v1/contacts/{contact_id} Partial update: `first_name`, `last_name`, `account_id` (nullable), `status`, `contact_access_rights`, `phone1`..`phone5`, `address1`, `custom_fields`. `emails` → 422; use the `/emails` endpoints. `primary_email` is read-only. ```bash curl -s -X PATCH "https://api.replypad.com/v1/contacts/per_7ca565d07cf38178" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"status": "inactive"}' | jq . ``` #### DELETE /v1/contacts/{contact_id} `204`. --- #### GET / POST / PUT /v1/contacts/{contact_id}/emails `POST` adds one address as **secondary** (body is a single object `{email_address}`); it becomes primary only if the contact had none. `PUT` replaces the whole list with an array — it must be non-empty and exactly one entry must have `primary_flag: true`. **Changing which address is primary is done with `PUT`.** ```bash # add a secondary address curl -s -X POST "https://api.replypad.com/v1/contacts/per_7ca565d07cf38178/emails" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"email_address": "johndoe@personal.example"}' | jq . # promote it to primary curl -s -X PUT "https://api.replypad.com/v1/contacts/per_7ca565d07cf38178/emails" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"email_address": "johndoe@personal.example", "primary_flag": true}, {"email_address": "john@acme.example", "primary_flag": false}]' | jq . ``` ```json { "data": [ { "email_address": "johndoe@personal.example", "primary_flag": true }, { "email_address": "john@acme.example", "primary_flag": false } ] } ``` #### DELETE /v1/contacts/{contact_id}/emails/{email} URL-encoded address. Returns `204`. **The last remaining address cannot be removed** — 409 `in_use`. ```bash curl -s -X DELETE "https://api.replypad.com/v1/contacts/per_7ca565d07cf38178/emails/johndoe%40personal.example" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" -o /dev/null -w '%{http_code}\n' ``` --- ### 4.6 Assets Customer hardware and software. `contact_id` accepts a contact **or** a user ID. **A request body may not exceed 16 KB.** Anything larger is refused with 413 `payload_too_large` before any validation runs. `type` is one of: `antivirus`, `backup_device`, `computer`, `hardware`, `laptop`, `mobile_device`, `monitor`, `network_equipment`, `other`, `printer`, `router`, `scanner`, `server`, `software`, `storage_device`, `switch`, `tablet`, `ups`, `web_hosting`. **`contract_id` is tied to the account.** It can only be set when the asset has an `account_id`, and the contract must belong to that account and be active — anything else is a 422. Changing or clearing `account_id` clears `contract_id` too, unless the same request sets a `contract_id` belonging to the new account. Writing the field (setting *or* clearing it, and changing an account that would clear it) requires full billing access rights; without it you get a 403. Reading it does not. --- #### GET /v1/assets Sorted by name ascending, 50 per page. Rows come back in the **short form**: `custom_fields` (expand `custom_fields`) and `description`/`notes` (expand `desc_notes`) are absent unless you ask for them. The spec models this as its own `AssetListItem` schema, so a generated client will not present those three as always-present. **Query:** `cursor`; `expand` (`custom_fields`, `desc_notes`); `search`; `account_id`; `contact_id`; `status` (csv of `active`, `inactive`). ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/assets?account_id=acc_fc8e252962bdf56a" | jq . ``` ```json { "data": [ { "id": "ast_e7b7bbe8aee71135", "name": "HP 250 G10 – 725G5EA", "status": "active", "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "contact_id": "per_7ca565d07cf38178", "contact": { "id": "per_7ca565d07cf38178", "first_name": "John", "last_name": "Doe", "primary_email": "john@acme.example", "status": "active", "web_url": "https://northwind.replypad.com/people/view/id/per_7ca565d07cf38178" }, "contract_id": "bct_10f10dd4d8487f06", "type": "laptop", "warranty_exp_date": "2027-11-19", "purchased_date": "2025-11-20", "installation_date": "2025-11-21", "location": null, "serial_no": null, "model": null, "web_url": "https://northwind.replypad.com/assets/view/id/ast_e7b7bbe8aee71135", "created_on": "2025-11-21T14:07:12Z", "updated_on": "2026-01-08T09:30:00Z" } ], "has_more": false, "next_cursor": null } ``` --- #### POST /v1/assets · PATCH /v1/assets/{asset_id} Same body schema for both; `name` is required on create, updates are partial. **Body:** `name` (≤100), `status`, `type`, `account_id`, `contact_id`, `contract_id` (see the account rule above), `warranty_exp_date`, `purchased_date`, `installation_date` (all `YYYY-MM-DD`), `location` (≤100), `serial_no` (≤30), `model` (≤50), `custom_fields`, `description` (≤4000), `notes` (≤4000). ```bash curl -s -X POST "https://api.replypad.com/v1/assets" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "name": "HP 250 G10 – 725G5EA", "status": "active", "type": "laptop", "account_id": "acc_fc8e252962bdf56a", "contact_id": "per_7ca565d07cf38178", "custom_fields": {"brand": "HP"} }' | jq . ``` `201 Created` — the asset as above, plus the detail-only fields: ```json { "data": { "id": "ast_e7b7bbe8aee71135", "custom_fields": { "brand": "HP", "user_manual_link": null }, "description": null, "notes": null, "created_on": "2025-11-21T14:07:12Z", "updated_on": "2025-11-21T14:07:12Z" } } ``` #### GET /v1/assets/{asset_id} No `expand` parameter — detail responses always include `custom_fields`, `description` and `notes`. #### DELETE /v1/assets/{asset_id} `204`. An asset referenced by other objects (e.g. a ticket) cannot be deleted — 409 `in_use`. --- ### 4.7 Items Billable products and services. **Names are unique per workspace** (409 `already_exists`). `type` is immutable after creation. **A request body may not exceed 16 KB.** Anything larger is refused with 413 `payload_too_large` before any validation runs. `priced_by` is `fixed_price` (always, for products), `hourly_rate`, or `hourly_user_rate` (service only; `price` is then always 0 and the acting user's rate is used instead). **`price` requires price visibility.** Without it the key is **not in the response at all** — not `null`, absent — on both the list and the detail call. Test with `"price" in item`, never `item["price"] is None`. Writing an item needs a stronger right than reading prices does, so the object echoed back from a `POST` or `PATCH` always carries `price`; only reads can lack it. --- #### GET /v1/items Sorted by name ascending, 50 per page. **Query:** `cursor`; `type` (csv of `product`, `service`); `status` (`active`, `inactive`). ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/items?type=service&status=active" | jq . ``` ```json { "data": [ { "id": "itm_d2c87af1aa36ca8f", "name": "Hard disk replacement", "type": "service", "priced_by": "hourly_rate", "price": "45.00", "status": "active", "created_on": "2025-11-21T14:07:12Z", "updated_on": "2026-01-08T09:30:00Z" }, { "id": "itm_ef905679180d36e2", "name": "Server installation", "type": "service", "priced_by": "hourly_rate", "price": "45.00", "status": "active", "created_on": "2025-11-21T14:07:12Z", "updated_on": "2026-01-08T09:30:00Z" } ], "has_more": false, "next_cursor": null } ``` #### POST /v1/items · PATCH /v1/items/{item_id} · GET /v1/items/{item_id} · DELETE /v1/items/{item_id} Create requires `name`, `type`, `priced_by`, `price`. Update is partial and **rejects `type`**. `description` (≤4000) and `status` are also accepted. `price` may be a number or a numeric string; it comes back as a 2-decimal string. Delete returns `204`, or 409 if the item is used by charges. ```bash curl -s -X POST "https://api.replypad.com/v1/items" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"name": "Hard disk replacement", "type": "service", "priced_by": "hourly_rate", "price": "45"}' | jq . ``` ```json { "data": { "id": "itm_d2c87af1aa36ca8f", "name": "Hard disk replacement", "type": "service", "priced_by": "hourly_rate", "price": "45.00", "status": "active", "created_on": "2025-11-21T14:07:12Z", "updated_on": "2025-11-21T14:07:12Z" } } ``` --- ### 4.8 Charges Billing entries. Three types: | `type` | Item must be | `user_id` | `time` | Money fields | |---|---|---|---|---| | `product_charge` | a product | not applicable | not accepted | apply | | `service_charge` | a service | required | optional | apply | | `time_entry` | a service | required | **required** | not applicable | **A request body may not exceed 16 KB.** Anything larger is refused with 413 `payload_too_large` before any validation runs. `type` is immutable. Money fields (`billable_flag`, `billed_flag`, `invoice_ref`, `price_rate`, `total_amount`) require price visibility and come back **`null`** without it — the keys stay present. The embedded `item` object goes the other way and drops its `price` key entirely. See [Field visibility](#field-visibility) for why the two differ and what `null` does and does not prove. Sending `billable_flag` or `price_rate` without price visibility is **403**, on create and on update alike — the field is refused, not ignored. **`contract_id` is tied to the account.** The contract must belong to the charge's account, be active and current, and permit the charge's type — anything else is a 422. Time entries never carry one. Omitting it on create picks the account's contract automatically, but only when exactly one active, current contract permits the type; with none or several the charge gets no contract. An explicit `null` also means none. On update an omitted `contract_id` keeps the current contract — except when the same request changes `account_id`, where the previous account's contract is never carried over: the automatic pick re-runs against the new account, leaving either that account's single qualifying contract or no contract at all. **A billed charge is read-only.** The only accepted update is the dedicated un-billing flow: a body containing **only** `billed_flag` (and optionally `invoice_ref`), with no other fields. --- #### GET /v1/charges Sorted by charge date descending, 50 per page. Requires billing access. **Query:** `cursor`; `expand` (`account`, `ticket`); `type` (csv of `product_charge`, `service_charge`, `time_entry`); `date_from` and `date_to` (`YYYY-MM-DD`, **must be supplied together**); `account_id`; `ticket_id`; `user_id`; `billed_flag`; `billable_flag`. `billed_flag` and `billable_flag` take `true` or `false` — one value only, anything else is 400. They select exactly the rows that serialise to that value, so **time entries match neither**: they have no billing state and come back `null` on both fields. Both parameters require price visibility; without it they are **403**, so that a field you may not read cannot be probed one value at a time. Two scoping rules narrow the result set before any filter you pass: - A caller who cannot see all charges gets **only their own rows**. Passing a `ticket_id` they may access lifts that, for that ticket. - A caller who may log time entries but not product or service charges sees **only `time_entry` rows**. Asking for `type=product_charge` then yields an empty page, not an error. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/charges?ticket_id=tkt_d44ee8a0fdabaec8&date_from=2026-03-01&date_to=2026-03-31" | jq . ``` ```json { "data": [ { "id": "crg_dc8faf9ddcbfcdc4", "type": "service_charge", "item_id": "itm_d2c87af1aa36ca8f", "item": { "id": "itm_d2c87af1aa36ca8f", "name": "Hard disk replacement", "type": "service", "priced_by": "hourly_rate", "price": "45.00" }, "account_id": "acc_fc8e252962bdf56a", "account": { "id": "acc_fc8e252962bdf56a", "name": "ACME Organization", "status": "active", "web_url": "https://northwind.replypad.com/accounts/view/id/acc_fc8e252962bdf56a" }, "ticket_id": "tkt_d44ee8a0fdabaec8", "ticket": null, "billable_flag": true, "billed_flag": false, "invoice_ref": null, "date": "2026-03-02", "user_id": "per_83771a454a075181", "time": { "start_time": "10:23", "end_time": "11:23", "hours_worked": "01:00" }, "quantity": "1.00", "price_rate": "45.00", "total_amount": "45.00", "contract_id": "bct_2f3a5a65fbb0cc95", "description": null, "created_on": "2026-03-02T11:31:05Z", "updated_on": "2026-03-02T11:31:05Z" } ], "has_more": false, "next_cursor": null } ``` --- #### POST /v1/charges **Required:** `type`, `item_id`, `date`. **Body fields:** `account_id`, `ticket_id`, `contract_id`, `user_id`, `time` (`{start_time, end_time, hours_worked}` as `HH:MM`), `quantity`, `billable_flag`, `price_rate`, `description`. `billed_flag` and `invoice_ref` are **rejected on create**. Account resolution rules, in order: 1. If `account_id` is given, it is used as-is — even if it differs from the ticket's account. An explicit `null` counts as a value (no fallback). 2. If omitted and `ticket_id` points at a ticket with an account, that account is used. 3. Otherwise a charge is rejected; a time entry is created with no account. On **update** the account is never derived from the ticket: changing `ticket_id` does not change `account_id`. `ticket_id` must reference an **active** ticket — not a draft, not archived. For `time`, either send `start_time` + `end_time` (and `hours_worked` is computed) or send `hours_worked` alone. ```bash curl -s -X POST "https://api.replypad.com/v1/charges" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "type": "service_charge", "item_id": "itm_d2c87af1aa36ca8f", "ticket_id": "tkt_d44ee8a0fdabaec8", "account_id": "acc_fc8e252962bdf56a", "contract_id": "bct_2f3a5a65fbb0cc95", "user_id": "per_83771a454a075181", "date": "2026-03-02", "time": {"start_time": "10:23", "end_time": "11:23"}, "billable_flag": true }' | jq . ``` `201 Created` — the charge object shown above, with `quantity: "1.00"`, `price_rate: "45.00"` and `total_amount: "45.00"` derived from the item and contract. --- #### GET /v1/charges/{charge_id} **Query:** `expand` — `account`, `ticket`. With `expand=ticket`: ```json { "data": { "ticket": { "id": "tkt_d44ee8a0fdabaec8", "number": 4022, "subject": "VPN connection failing for remote employees", "status": "open", "web_url": "https://northwind.replypad.com/conversations/view/id/tkt_d44ee8a0fdabaec8" } } } ``` --- #### PATCH /v1/charges/{charge_id} Partial update. To mark a charge billed, send **only** these fields: ```bash curl -s -X PATCH "https://api.replypad.com/v1/charges/crg_dc8faf9ddcbfcdc4" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"billed_flag": true, "invoice_ref": "INV-2026-03-0352"}' | jq . ``` ```json { "data": { "id": "crg_dc8faf9ddcbfcdc4", "billed_flag": true, "invoice_ref": "INV-2026-03-0352", "updated_on": "2026-04-02T09:13:59Z" } } ``` Adding any other field to that body, or editing a charge that is already billed, fails with 422 `locked`. To edit a billed charge: first `PATCH` `{"billed_flag": false}`, then make the real change. #### DELETE /v1/charges/{charge_id} `204`. --- ### 4.9 Contracts (read-only) Service contracts. No create, update or delete through the API. **Two access levels, and they return different objects.** Reading contracts needs only the right to log charges — whoever logs a charge has to be able to name the contract it points at. Seeing the *whole* contract needs full billing access rights. Without full billing access rights: - the contract collapses to the fields that identify it — `id`, `name`, `account_id`, `status`, plus `account` when expanded. The other nine (`duration_type`, `interval`, `start_date`, `end_date`, `description`, `custom_fields`, `web_url`, `created_on`, `updated_on`) are **absent from the response**, not `null`; - `expand` accepts `account` only; - **`account_id` is mandatory** — listing every contract in the workspace is **403 `account_id_required`**. This holds on paged requests too, where `account_id` travels inside the cursor; - `GET /v1/metadata/custom-fields/contracts` is 403, since the values it describes are not returned to you anyway. Check `permissions.contracts_read` and `permissions.contracts_write` on `GET /v1/tokens/me` to tell which level you are at: full billing access rights is what `contracts_write` reports. #### GET /v1/contracts Sorted by name ascending, 50 per page. **Query:** `cursor`; `expand` (`account`, `description`, `custom_fields` — `account` only without full billing access rights); `account_id` (**required** without full billing access rights); `status` (csv of `draft`, `active`, `completed`, `canceled`). ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/contracts?account_id=acc_fc8e252962bdf56a&status=active" | jq . ``` ```json { "data": [ { "id": "bct_2f3a5a65fbb0cc95", "name": "Contract with Acme - 2025/27", "account_id": "acc_fc8e252962bdf56a", "status": "active", "duration_type": "recurring", "interval": { "unit": "yearly", "total_intervals": 3 }, "start_date": "2025-01-01", "end_date": "2027-12-31", "web_url": "https://northwind.replypad.com/contracts/view/id/bct_2f3a5a65fbb0cc95", "created_on": "2024-12-21T13:48:01Z", "updated_on": "2024-12-21T16:07:35Z" } ], "has_more": false, "next_cursor": null } ``` `interval` is `null` for `single_term` contracts. The same page seen by a token without full billing access rights: ```json { "data": [ { "id": "bct_2f3a5a65fbb0cc95", "name": "Contract with Acme - 2025/27", "account_id": "acc_fc8e252962bdf56a", "status": "active" } ], "has_more": false, "next_cursor": null } ``` #### GET /v1/contracts/{contract_id} **Query:** `expand=account`. With full billing access rights the detail response always adds `description` and `custom_fields`; without it you get the same four identifying fields as in the list: ```json { "data": { "description": "This contract is recurring ...", "custom_fields": { "service_scope": "Maintenance" } } } ``` --- ### 4.10 Read-only lookups These resources are read-only through the API — they are configured in the web UI. All lists are 50 per page and take `cursor`. --- #### GET /v1/users · GET /v1/users/{user_id} Workspace staff. Sorted by first then last name. **Query (list):** `status` (csv of `active`, `inactive`). The detail response adds `phone1`..`phone5`, `address1`, `custom_fields` and `web_url` **only when the token's user is an admin**. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/users?status=active" | jq . ``` ```json { "data": [ { "id": "per_83771a454a075181", "first_name": "Jane", "last_name": "Doe", "nickname": "@jane", "primary_email": "jane@northwind.example", "emails": [ { "email_address": "jane@northwind.example", "primary_flag": true } ], "status": "active", "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" }, { "id": "per_da82708cc65f2b1e", "first_name": "Mark", "last_name": "Lee", "nickname": "@mark", "primary_email": "mark@northwind.example", "emails": [ { "email_address": "mark@northwind.example", "primary_flag": true } ], "status": "active", "created_on": "2026-02-21T15:19:02Z", "updated_on": "2026-02-21T15:23:24Z" } ], "has_more": false, "next_cursor": null } ``` Use this to resolve an `assignee_id` or a follower before writing to a ticket. --- #### GET /v1/teams · GET /v1/teams/{team_id} **Query (list):** `status` (csv of `active`, `inactive`). Sorted by name. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/teams" | jq . ``` ```json { "data": [ { "id": "tea_1f29804c7ad271bc", "name": "Support", "default_flag": true, "status": "active", "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } ], "has_more": false, "next_cursor": null } ``` --- #### GET /v1/labels · GET /v1/labels/{label_id} Sorted by name; deleted labels are not returned. No filters beyond `cursor`. This is the only way to discover valid label IDs/names before attaching them to a ticket. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/labels" | jq . ``` ```json { "data": [ { "id": "lbl_33f52594685b793e", "name": "Follow Up", "created_on": "2026-02-21T15:19:02Z", "updated_on": "2026-02-21T15:23:24Z" }, { "id": "lbl_9fa361758f05fa48", "name": "VIP", "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } ], "has_more": false, "next_cursor": null } ``` --- #### GET /v1/stars · GET /v1/stars/{star_key} Star markers, addressed by slug. **Query (list):** `status`. A workspace can rename a star, so `name` may differ from `system_name`; `star_key` is the stable identifier to use in ticket writes and filters. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/stars/red_star" | jq . ``` ```json { "data": { "star_key": "red_star", "system_name": "Red star", "name": "Escalated", "status": "active", "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } } ``` --- #### GET /v1/mailboxes · GET /v1/mailboxes/{mailbox_id} Shared mailboxes. Sorted by name. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/mailboxes" | jq . ``` ```json { "data": [ { "id": "mbx_b5c73d081a013a4b", "name": "Support", "email_address": "support@northwind.example", "aliases": null, "default_flag": true, "personalized_reply_enabled": true, "personalized_avatars_enabled": true, "default_team_id": "tea_1f29804c7ad271bc", "default_team": { "id": "tea_1f29804c7ad271bc", "name": "Support" }, "autoresponse_enabled": true, "autoresponse_custom_enabled": false, "autoresponse_custom_text": null, "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } ], "has_more": false, "next_cursor": null } ``` --- #### GET /v1/kb-categories · GET /v1/kb-categories/{category_id} Knowledge-base categories. Sorted by name; no filters beyond `cursor`. The detail response carries the same fields as a list row. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/kb-categories" | jq . ``` ```json { "data": [ { "id": "kbc_669d7fd3f95a483f", "name": "General", "default_flag": true, "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } ], "has_more": false, "next_cursor": null } ``` ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/kb-categories/kbc_669d7fd3f95a483f" | jq . ``` ```json { "data": { "id": "kbc_669d7fd3f95a483f", "name": "General", "default_flag": true, "created_on": "2026-02-21T14:07:12Z", "updated_on": "2026-02-21T14:07:12Z" } } ``` --- #### GET /v1/kb-articles · GET /v1/kb-articles/{article_id} Sorted by title ascending. **Without a `status` filter articles of every status are returned.** **Query (list):** `cursor`; `expand=content` (adds `description` and `body`); `kb_category_id`; `status` (csv of `draft`, `published`, `archived`). Article text is plain text — no HTML, no images, no attachments. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/kb-articles/kba_bf74044c3b65ad94" | jq . ``` ```json { "data": { "id": "kba_bf74044c3b65ad94", "title": "Installing Windows 11", "kb_category_id": "kbc_669d7fd3f95a483f", "kb_category": { "id": "kbc_669d7fd3f95a483f", "name": "General" }, "status": "published", "public_flag": false, "public_level": null, "public_selected_accounts": null, "description": "How to install Windows 11", "body": "Follow this procedure...", "web_url": "https://northwind.replypad.com/articles/view/id/kba_bf74044c3b65ad94", "created_on": "2026-03-18T13:48:01Z", "updated_on": "2026-03-18T16:07:35Z" } } ``` The detail endpoint always includes `description` and `body`; the list needs `expand=content`. --- #### GET /v1/saved-responses · GET /v1/saved-responses/{response_id} Canned replies. Sorted by name. **Query (list):** `cursor`; `expand=content` (adds `body`). The detail response always includes `body` and `status`. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/saved-responses/res_5c5585fc630d2d8b" | jq . ``` ```json { "data": { "id": "res_5c5585fc630d2d8b", "name": "Install procedure", "magic_keyword": "install", "body": "Install Procedure\nPrepare to install the server...", "teams_filter_enabled": false, "teams_filter": null, "status": "active", "created_on": "2025-11-21T14:07:12Z", "updated_on": "2026-01-08T09:30:00Z" } } ``` `teams_filter_enabled: true` means the response applies only to tickets whose team appears in `teams_filter`. --- ### 4.11 Tokens One endpoint, no parameters, no writes. It describes the token you are already holding; the token value itself is never returned, and tokens are still created and revoked in the web UI only. #### GET /v1/tokens/me ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/tokens/me" | jq . ``` ```json { "data": { "principal_type": "user", "name": "Nightly sync agent", "read_only_token": false, "expires_on": "2026-12-31", "user": { "id": "per_dfba3ff87fc19c96", "first_name": "John", "last_name": "Doe", "nickname": "@john", "primary_email": "johndoe@example.com" }, "permissions": { "privilege_group": "limited", "accounts_read": true, "accounts_write": false, "contacts_read": true, "contacts_write": false, "assets_read": true, "assets_write": false, "charges_read": true, "charges_write": true, "time_entries_read": true, "time_entries_write": true, "contracts_read": true, "contracts_write": false, "items_read": true, "items_write": false, "tickets_read": true, "tickets_write": true } } } ``` | Field | Type | Meaning | |---|---|---| | `principal_type` | string | Always `user`. Workspace-level "generic" tokens are not served here — see below. | | `name` | string or null | The label given to the token when it was created. Not unique. | | `read_only_token` | boolean | Writes are refused with 403 `read_only_token`. | | `expires_on` | string or null | `YYYY-MM-DD`. `null` means it never expires. | | `user` | object | `id`, `first_name`, `last_name`, `nickname`, `primary_email` of the user the token acts as. `user.id` is the `user_id` you need for service charges and time entries. | | `permissions` | object | `privilege_group` plus 16 booleans, below. | `permissions.privilege_group` is one of `admin`, `power_user`, `standard`, `limited`. The 16 booleans are `_read` and `_write` for `accounts`, `contacts`, `assets`, `charges`, `time_entries`, `contracts`, `items` and `tickets`, in that order. Three things to know about them: - **`*_write` is the effective answer, not the raw privilege.** On a read-only token every `*_write` is `false`, whatever the user could otherwise do. So `permissions.tickets_write` alone is enough to decide whether to try a write; you do not have to AND it with `read_only_token` yourself. - **Charges and time entries are permissioned separately.** A user restricted to logging time gets `time_entries_write: true` with `charges_write: false`. - **`tickets_read` is always `true`.** Ticket visibility is decided per ticket rather than by a blanket read privilege, and a ticket you may not see is a 404. `charges_read` is the general case. A caller with `charges_read: false` can still see product and service charges on one specific ticket, by passing a `ticket_id` they have access to — a single boolean cannot express that. **Non-user tokens.** `principal_type` is always `user` in v1. A token that is not linked to a user is not supported by this endpoint and gets **422 `not_supported`**. --- ### 4.12 Metadata: custom-field definitions Six endpoints, one per resource type, all with the same response shape and no parameters: - `GET /v1/metadata/custom-fields/tickets` - `GET /v1/metadata/custom-fields/accounts` - `GET /v1/metadata/custom-fields/contacts` - `GET /v1/metadata/custom-fields/assets` - `GET /v1/metadata/custom-fields/contracts` - `GET /v1/metadata/custom-fields/users` (admin tokens only) Custom-field keys are workspace-specific. **Call these before writing `custom_fields`** — an undefined key is a validation error, and the definitions tell you the required value format. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/metadata/custom-fields/tickets" | jq . ``` ```json { "data": [ { "key": "severity_level", "label": "Severity Level", "type": "string", "created_on": "2026-02-02T13:48:01Z", "updated_on": "2026-02-02T13:48:01Z" }, { "key": "extra_note", "label": "Extra Note", "type": "string", "created_on": "2026-02-11T13:48:01Z", "updated_on": "2026-02-11T13:48:01Z" } ] } ``` Values are **always sent as strings**, formatted per `type`: | `type` | Format | Example value | |---|---|---| | `string` | free text, ≤255 characters | `"Happened again"` | | `date` | `YYYY-MM-DD` | `"2026-03-19"` | | `url` | absolute URL | `"https://hp.example/manual.pdf"` | Keys are ≤40 characters. Even a numeric-looking field is a string: `{"severity_level": "5"}`, not `{"severity_level": 5}`. User custom fields appear on `GET /v1/users/{user_id}` for admin tokens. --- ## 5. Error catalog Every error, from every endpoint, has exactly this body: ```json { "error": { "code": "validation_failed", "message": "Ticket subject exceeds the maximum length of 255 characters." } } ``` **Branch on `code`, never on `message`.** The spec fixes the set of `code` values; it does not specify message text, and messages differ between endpoints for the same code. The messages in the table below are real examples, not a contract — treat them as illustrative only. Since the envelope never varies, the table gives the complete example body for each code: substitute the row's `code` and `message` into the shape above. ### All 28 codes | Code | HTTP | Triggered by | Example `message` | Fix to apply | |---|---|---|---|---| | `token_missing` | 401 | No `Authorization` header, or it is not a well-formed `Bearer` token | `Missing or invalid Authorization header.` | Send the header correctly. Do not retry. | | `token_invalid` | 401 | Unknown token, or its workspace cannot be resolved (also: workspace suspended or past due) | `Invalid API token.` | Check the token value; escalate to a human. Do not retry. | | `token_revoked` | 401 | Token deactivated in the web UI | `API token has been revoked.` | Obtain a new token. Do not retry. | | `token_expired` | 401 | Past its expiration date | `API token has expired.` | A human must extend or reissue it in the UI. Do not retry. | | `forbidden` | 403 | The user behind the token lacks the privilege for this operation or data area (accounts/contacts, assets, financial data); also any write to a ticket or comment that is scheduled for publishing | `Insufficient privileges for this operation.` / `Ticket is scheduled for publishing.` / `Comment is scheduled for publishing.` | Stop; ask an admin to grant the privilege. Do not retry, do not try another endpoint to get around it. For a scheduled item, wait for it to post or have someone cancel the schedule in the web app. | | `read_only_token` | 403 | Any write with a read-only token | `API token has readonly access.` | Use a read-write token. Read `read_only_token` from `GET /v1/tokens/me` at startup instead of waiting for this. | | `account_id_required` | 403 | A list this token may only read one account at a time was called without `account_id` — contracts, for a caller without full billing access rights | `The account_id parameter is required.` | Pass `account_id`. There is no way to list the resource workspace-wide with this token. | | `invalid_request` | 400 | Malformed JSON body, or invalid/unknown query parameters (including a bad `expand` value) | `Request body is not valid JSON.` | Fix the request syntax or the parameter name/value. Not retryable. | | `invalid_cursor` | 400 | Cursor is corrupt, or its filters/sort/page size disagree with the request | `Pagination cursor does not match the request filters.` | Restart pagination from page 1 with the exact same filters, and do not edit cursors. | | `invalid_id_format` | 400 | An ID is not well formed for its type | `Invalid ticket id format.` | Fix ID construction; never build IDs by hand. Not retryable. | | `payload_too_large` | 413 | The request body is over the resource's limit — 128 KB for tickets and their sub-resources, 16 KB for accounts, contacts, assets, items and charges | `Request body exceeds the maximum size allowed in bytes.` | Send less in one call: split a bulk array across requests, or trim oversized text. The body is refused before validation, so no field errors come back. Not retryable as-is. | | `validation_failed` | 422 | A field value is out of range, wrong type, or fails a format rule — including an array over its cardinality cap | `Ticket subject exceeds the maximum length of 255 characters.` | Correct the value. Check maximum lengths, enum spellings, and the caps in section 3. | | `missing_required_field` | 400 / 422 | A required field is absent (e.g. ticket with neither `subject` nor `comments`) | `Field 'date' is required.` | Add the field. | | `unknown_field` | 422 | A field name the endpoint does not accept — including read-only fields echoed back from a GET | `Unknown field 'web_url' in 'ticket'.` | Remove it. Send minimal PATCH bodies containing only what you are changing. | | `reference_not_found` | 422 | An ID **inside the body** does not resolve — e.g. an `assignee_id` that is not a user | `assignee_id not found or not an active staff member.` | Resolve the ID with a lookup call first. Distinct from the 404 `not_found` below, so the code alone tells them apart. | | `invalid_state` | 422 | The object's current state forbids the operation — updating a published comment, moving a ticket back to `draft` | `Only draft comments can be updated.` | Re-read the object and branch on its state; some transitions are one-way. | | `wrong_endpoint` | 422 | A field that belongs to a sub-resource was sent to the parent — `status`, `comments`, `labels`, `cc_recipients`, `followers` on `PATCH /v1/tickets/{ticket_id}`; `domains` on accounts; `emails` on contacts. Also a comment create/publish/discard on a draft ticket, which belongs to the ticket endpoints | `Use /tickets/{ticket_id}/status to change the status.` | Move that field to its dedicated endpoint. | | `not_supported` | 422 | The operation exists but is not offered — setting `status: "archived"`, creating a `customer_comment` | `Tickets cannot be archived through the API.` | Do not attempt it; there is no alternative route. | | `locked` | 422 | The object is frozen — most often a charge with `billed_flag: true` | `Charge is billed and cannot be modified.` | Un-bill first (`PATCH` with only `{"billed_flag": false}`), then edit. | | `not_found` | 404 | The object does not exist **or is not visible to this token** | `Ticket not found.` | Do not retry. Verify the ID; if it is known-good, the token's user lacks access — treat it as a permission problem. | | `method_not_allowed` | 405 | The path exists but not with this method (e.g. `POST /v1/contracts`) | `Method Not Allowed. Expected GET.` | Use a documented method from the index in section 1. The response carries an `Allow` header naming the method the path does accept. | | `conflict` | 409 | A general collision with existing data | `The request conflicts with the current state.` | Re-read the object and reconcile before retrying. | | `already_exists` | 409 | A unique value is taken — account name, item name, contact/user email address, domain claimed by another account | `An account with this name already exists.` | Look the existing object up (`GET /v1/accounts?name=...`) and use it instead of creating. | | `in_use` | 409 | The object is still referenced — deleting an asset attached to a ticket, or a contact's last email address | `Asset is referenced by one or more tickets.` | Remove the references first, or keep the object and deactivate it (`status: "inactive"`) instead. | | `rate_limited` | 429 | Over the per-token, per-workspace or per-IP limit | `API rate limit exceeded. Please try again later.` | Sleep for `Retry-After` seconds + jitter, then retry (max 3 times). Reduce concurrency; remember writes cost double. | | `too_many_errors` | 429 | Too many consecutive failing calls; the token is temporarily blocked | `Too many failed requests. The token is temporarily blocked.` | **Stop.** Fix the underlying error, then wait out the block — `Retry-After` gives the seconds remaining. Retrying before then keeps you in the failure path. | | `internal_error` | 500 | Unexpected server error | `Internal server error.` | Retry at most twice with backoff (2s, 8s), then escalate. Never parse the message — it is always this fixed string. | | `service_unavailable` | 503 | Planned maintenance window; returned by every endpoint | `The API is temporarily unavailable for maintenance. Try again shortly.` | Sleep for `Retry-After` seconds, then resume. The call never reached any data, so even a `POST` is safe to repeat. | ### Worked examples Missing credentials — `401`: ```json { "error": { "code": "token_missing", "message": "Missing or invalid Authorization header." } } ``` Read-only token attempting a write — `403`: ```json { "error": { "code": "read_only_token", "message": "API token has readonly access." } } ``` Sending a read-only or misspelled field — `422`. This is the single most common agent error, caused by echoing back a GET response: ```json { "error": { "code": "unknown_field", "message": "Unknown field 'due_date' in 'ticket'." } } ``` Changing status on the wrong endpoint — `422`: ```json { "error": { "code": "wrong_endpoint", "message": "Use /tickets/{ticket_id}/status to change the status." } } ``` Duplicate unique value — `409`: ```json { "error": { "code": "already_exists", "message": "An account with this name already exists." } } ``` Rate limited — `429`, with a `Retry-After` header giving the seconds to wait: ```json { "error": { "code": "rate_limited", "message": "API rate limit exceeded. Please try again later." } } ``` Maintenance window — `503`, also with `Retry-After`. ```json { "error": { "code": "service_unavailable", "message": "The API is temporarily unavailable for maintenance. Try again shortly." } } ``` ### Decision rules - **404 on a write is not a retry case.** It means "gone, or invisible to you". - **`not_found` appears at two statuses.** 404 = the object in the *path*; 422 = an ID in the *body*. Same string, different meaning — always read the HTTP status alongside the code. - **Only 429, 500 and 503 are retryable.** Everything else is deterministic; retrying the identical request will produce the identical error and push you toward `too_many_errors`. 503 is the gentlest of the three: it is the only status after which repeating a `POST` cannot duplicate, because the call never reached any data. - **Never parse `message` to make decisions.** Log it for humans; branch on `code` plus status. --- ## 6. Common workflows Three end-to-end sequences over the fixture workspace. Each step consumes an ID produced by an earlier step, so the calls can be run in order as written. ### Workflow A — inbound request to resolved ticket The full life of `tkt_d44ee8a0fdabaec8`: an email arrives from `john@acme.example`, gets triaged, answered and closed. **A1. Resolve the sender to a contact.** Never create blindly — email addresses are unique workspace-wide, so a duplicate create is 409. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/contacts?search=john%40acme.example" | jq -r '.data[0].id' ``` → `per_7ca565d07cf38178`. If `data` is empty, create the contact (`POST /v1/contacts`) or let step A2 create it inline via the `contact` object. **A2. Resolve the assignee.** Ticket writes need a user ID, not a name. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/users?status=active" \ | jq -r '.data[] | select(.primary_email == "mark@northwind.example") | .id' ``` → `per_da82708cc65f2b1e`. **A3. Create the ticket as a draft** so it can be reviewed before the customer is notified. ```bash curl -s -X POST "https://api.replypad.com/v1/tickets" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "subject": "VPN connection failing for remote employees", "comments": [{"body": "Several employees reported that they cannot establish a VPN connection when working remotely. The connection attempt fails with an authentication timeout error.", "type": "user_public_comment"}], "contact_id": "per_7ca565d07cf38178", "account_id": "acc_fc8e252962bdf56a", "assignee_id": "per_da82708cc65f2b1e", "team_id": "tea_1f29804c7ad271bc", "priority_flag": true, "status": "draft" }' | jq -r '.data.id' ``` → `tkt_d44ee8a0fdabaec8`, `status: "draft"`, **no `number` yet**. **A4. Triage — label it and copy a colleague in.** Both are separate calls; neither can be done through `PATCH /v1/tickets/{ticket_id}`. Bodies are bare arrays. ```bash curl -s -X POST "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/labels" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"id": "lbl_9fa361758f05fa48"}]' | jq -r '.data[].name' curl -s -X POST "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/cc-recipients" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '[{"id": "per_fcb862e8856489cf"}]' | jq -r '.data[].email_address' ``` **A4b. Revise the draft's body** (optional). While the ticket is still a draft its body is its only comment, and `PATCH /v1/tickets/{ticket_id}` has no `body` field — so the text is edited through the comment endpoint. ```bash BODY_ID=$(curl -s "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ | jq -r '.data[0].comment_id') curl -s -X PATCH "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments/$BODY_ID" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"body": "Several employees cannot establish a VPN connection when working remotely; the attempt fails with an authentication timeout error."}' | jq -r '.data.body' ``` Only `body` and `from_id` may be changed on a draft ticket's comment — `type` is 422 `invalid_state`, and `POST`, `PUT .../publish` and `DELETE` on it are 422 `wrong_endpoint`. **A5. Publish the draft.** This assigns the ticket `number` and sends the first comment. ```bash curl -s -X PUT "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/status" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"status": "open"}' | jq '{number: .data.number, status: .data.status, published_on: .data.published_on}' ``` ```json { "number": 4022, "status": "open", "published_on": "2026-03-02T10:17:44Z" } ``` **A6. Draft a reply, then publish it.** Two calls, because the reply should be reviewed before it reaches the customer. **The ID changes on publish.** ```bash DRAFT_ID=$(curl -s -X POST "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"body": "Hi John, we are looking into the VPN authentication timeouts now.", "status": "draft", "type": "user_public_comment"}' \ | jq -r '.data.comment_id') # DRAFT_ID = cmt_99d88d369db3d6b7 curl -s -X PATCH "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments/$DRAFT_ID" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"body": "Hi John, the VPN authentication timeouts are caused by an expired RADIUS certificate. We are replacing it now."}' > /dev/null PUBLISHED_ID=$(curl -s -X PUT "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/comments/$DRAFT_ID/publish" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ | jq -r '.data.comment_id') # PUBLISHED_ID = cmt_5a3c0d8f2b41e7aa — $DRAFT_ID is now dead ``` **A7. Close the ticket.** ```bash curl -s -X PUT "https://api.replypad.com/v1/tickets/tkt_d44ee8a0fdabaec8/status" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"status": "closed"}' | jq -r '.data.status' ``` → `closed`. Safe to repeat — the status endpoint is idempotent. --- ### Workflow B — bill an hour of work against that ticket Continues from Workflow A. Requires billing access rights; without them the money fields come back `null` and writes to them fail with 403 `forbidden`. **B1. Find the service item to charge against.** `product_charge` needs a product; `service_charge` and `time_entry` need a service. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/items?type=service&status=active" \ | jq -r '.data[] | select(.name == "Hard disk replacement") | .id' ``` → `itm_d2c87af1aa36ca8f`. **B2. Find the account's active contract**, so the charge is priced under it. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/contracts?account_id=acc_fc8e252962bdf56a&status=active" \ | jq -r '.data[0].id' ``` → `bct_2f3a5a65fbb0cc95`. The contract must belong to the charge's account, be active, and permit the charge type — otherwise the create fails with 422. **B3. Create the charge.** The ticket must be **active** (not draft, not archived) — which is why this comes after A5. ```bash curl -s -X POST "https://api.replypad.com/v1/charges" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "type": "service_charge", "item_id": "itm_d2c87af1aa36ca8f", "ticket_id": "tkt_d44ee8a0fdabaec8", "account_id": "acc_fc8e252962bdf56a", "contract_id": "bct_2f3a5a65fbb0cc95", "user_id": "per_83771a454a075181", "date": "2026-03-02", "time": {"start_time": "10:23", "end_time": "11:23"}, "billable_flag": true }' | jq '{id: .data.id, hours: .data.time.hours_worked, total: .data.total_amount}' ``` ```json { "id": "crg_dc8faf9ddcbfcdc4", "hours": "01:00", "total": "45.00" } ``` `hours_worked` was computed from the start/end times; `price_rate` and `total_amount` came from the item's hourly rate under the contract. **B4. Mark it billed.** The body must contain **only** `billed_flag` and optionally `invoice_ref` — adding any other field fails. ```bash curl -s -X PATCH "https://api.replypad.com/v1/charges/crg_dc8faf9ddcbfcdc4" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"billed_flag": true, "invoice_ref": "INV-2026-03-0352"}' \ | jq '{billed: .data.billed_flag, invoice: .data.invoice_ref}' ``` The charge is now read-only. To correct it later, send `{"billed_flag": false}` first, then the real edit — otherwise 422 `locked`. --- ### Workflow C — onboard a new customer Create an organization, its first contact, and one of its assets, then read back its tickets. **C1. Check the account name is free** — names are unique, and this makes the create safe to retry after a timeout. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/accounts?name=ACME%20Organization" | jq -r '.data | length' ``` → `0` means free; `1` means it already exists — use that object's `id` instead of creating. **C2. Create the account with its email domain.** `domains` is accepted only at creation; afterwards it needs the `/domains` endpoints. ```bash curl -s -X POST "https://api.replypad.com/v1/accounts" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "name": "ACME Organization", "contact_access_rights": "restricted", "domains": [{"domain_name": "acme.example"}], "custom_fields": {"industry": "Tech"} }' | jq -r '.data.id' ``` → `acc_fc8e252962bdf56a`. Mail from `acme.example` now routes to this account automatically. **C3. Create the first contact**, attached to that account. ```bash curl -s -X POST "https://api.replypad.com/v1/contacts" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "first_name": "John", "last_name": "Doe", "account_id": "acc_fc8e252962bdf56a", "emails": [{"email_address": "john@acme.example", "primary_flag": true}] }' | jq -r '.data.id' ``` → `per_7ca565d07cf38178`. **C4. Register their laptop.** Verify the custom-field key first — keys are workspace-specific and an undefined one is rejected. ```bash curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/metadata/custom-fields/assets" | jq -r '.data[].key' # → user_manual_link, brand curl -s -X POST "https://api.replypad.com/v1/assets" \ -H "Authorization: Bearer $REPLYPAD_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "name": "HP 250 G10 – 725G5EA", "type": "laptop", "account_id": "acc_fc8e252962bdf56a", "contact_id": "per_7ca565d07cf38178", "serial_no": "5CD3210XYZ", "purchased_date": "2025-11-20", "warranty_exp_date": "2027-11-19", "custom_fields": {"brand": "HP"} }' | jq -r '.data.id' ``` → `ast_e7b7bbe8aee71135`. **C5. Read back the account's open tickets**, paging correctly. Keep the filters byte-identical across pages or the cursor is rejected. ```bash CURSOR="" while :; do RESP=$(curl -s -H "Authorization: Bearer $REPLYPAD_TOKEN" -H "Accept: application/json" \ "https://api.replypad.com/v1/tickets?status=open&account_id=acc_fc8e252962bdf56a${CURSOR:+&cursor=$CURSOR}") echo "$RESP" | jq -r '.data[] | "\(.number)\t\(.subject)"' [ "$(echo "$RESP" | jq -r '.has_more')" = "true" ] || break CURSOR=$(echo "$RESP" | jq -r '.next_cursor' | jq -sRr @uri) done ```