How to Import Tickets into Your Helpdesk via API
Migrations fail on the boring parts: duplicates, timestamps, attachments. Here is how to import tickets over REST without losing history.
Nobody plans a ticket import. You plan a helpdesk switch, and then discover that four years of support history has to come with you. The API docs show you a tidy POST /tickets and stop there — which is fine for the first ticket and useless for the ten-thousandth.
This guide walks the whole path. Every API request and response below, except where noted, was run against GoPimi's live API at https://core.gopimi.com/api/v1 on 2026-08-12; responses are trimmed and addresses redacted, never invented, and where the API cannot do something this guide says so instead of hand-waving.
When You Need Bulk Import
Three situations account for almost every import, and they have different shapes.
- Switching helpdesks. When switching away from a seat-priced helpdesk, you typically have a Zendesk or Freshdesk export sitting in a folder: JSON or CSV, one row per ticket, a nested array of comments, attachment URLs pointing back at the old vendor. The data is complete and structured — the work is mapping it.
- Consolidating channels. Support currently lives in three places: a shared mailbox, a chat widget, and a spreadsheet somebody maintains by hand. Nothing shares a schema. The work here is normalising before you import anything.
- Backfilling history. The new tool is already live and taking new tickets, and you want last year's conversations searchable next to them. This is the riskiest of the three, because you are writing into a system with real traffic in it. If your source is a mailbox, read turning email into tickets first — piping the mailbox forward is often a better answer than importing it backward.
The shape is the same in all three: hundreds to a few thousand tickets, each with one contact, an ordered list of messages alternating between customer and agent, some internal notes, a status, and — for maybe five percent — attachments. Volume is rarely the hard part; a few thousand tickets is a few thousand HTTP calls, and that is an afternoon. The hard part is that an import is not idempotent by default, so every failure halfway through leaves you choosing between duplicates and gaps.
What an Import Actually Has to Preserve
Write this list down before any code — the answers decide your data model. Here is each item, and what GoPimi's API does with it.
Contacts, deduplicated by email. Handled server-side. POST /tickets takes either a contact_id or a contact object of name and email; send the object and the contact is created on first use, then matched on every later ticket with the same address. Two tickets created back to back with one email shared a single contact_id. No email-to-id map to maintain — though caching the returned contact_id saves a lookup later.
Message order. Preserved, but only because you control it. Messages are appended by separate calls, so post them in export order. There is no ordering field, and no way to insert a message between two existing ones — get it wrong and the only fix is deleting the ticket and re-importing it.
Message direction — customer versus agent. Partially. The description you send at ticket creation becomes the first message, attributed to the contact (sender_type: App\Models\Contact). Every message you post afterwards is attributed to the user your token belongs to, and comes back as App\Models\User — an agent. The opening customer message survives; later customer replies carry no customer attribution unless you label them in the body yourself. A prefix line like Customer reply — [email protected]: is ugly, honest, and searchable.
Internal notes versus public replies. Preserved cleanly. The message type enum is email or internal_note, and a note comes back with none of the email transport fields populated — no from_email, to_email or in_reply_to. That distinction matters more than it looks; see the warning below.
Status. Two steps. The status enum is open, in_progress, pending and closed, but status is not part of the create request — imported tickets land as open. Collect the ids that were closed in your export and close them afterwards in batches.
Original timestamps. Not possible. Make peace with this before you start. created_at is server-set, and a client-supplied value is not rejected — it is silently ignored. Sending "created_at": "2020-01-01T00:00:00Z" returned 201 with "created_at": "2026-08-12T23:40:23.000000Z", the real server time, and no validation error to warn you. Same for updated_at, closed_at and first_response_at: they exist on responses, never on requests, and first_response_at is computed from message activity — null on a ticket that never got an agent reply, auto-set on one that did. Every imported ticket will show today as its creation date.
The workaround is a convention, not a feature: carry the original date in text where it stays searchable, and tag the batch so imported tickets are filterable later.
{
"contact": { "name": "Jane Smith", "email": "[email protected]" },
"subject": "[import 2024-03-11] Refund request for order #1042",
"description": "[Imported from Zendesk #48812 — original date 2024-03-11 09:22 UTC]\n\nHi, I was charged twice for order #1042...",
"priority": "medium"
} Expect one more surprise: the server prefixes whatever subject you send with Ticket #<id> - . Sending [import-test] probe B stored Ticket #319 - [import-test] probe B. An importer cannot produce a bare subject line, so nothing downstream should round-trip on exact subject equality.
Why "Bulk API" Usually Means Bulk Actions
Search any helpdesk's docs for bulk and you will find an endpoint. Read it closely before you plan your import around it. GoPimi's is POST /tickets/bulk, and its request body is an action plus an array of ids — up to 100 — with optional agent_id and tag_id. The action enum is trash, spam, not_spam, restore, force_delete, assign, unassign, close, reopen, mark_read, mark_unread, add_tag and remove_tag. It returns {"data":{"affected":4}}.
Every one of those verbs operates on tickets that already exist. There is no action that creates anything, and the body has no room for a ticket payload — only integers. This is not a GoPimi quirk; "bulk" in a helpdesk API usually means bulk lifecycle actions, because that is what the agent UI needs. There is no bulk-create endpoint anywhere in the public spec.
So import is a per-ticket POST loop, and that hands you two problems the API will not solve: it will not tell you whether you already imported a row, and it will not queue your requests for you. Idempotency and rate limits are yours. The endpoint is still useful — just at the end, for status mapping, tagging, and rollback.
The Import Loop, for Real
Start by confirming the token. Auth is a bearer token; a missing or bad one returns 401 {"message":"Unauthenticated"}.
export API="https://core.gopimi.com/api/v1"
export WS=3
export TOKEN="your-token"
curl -sS -i "$API/account" -H "Authorization: Bearer $TOKEN" | head -3
# HTTP/1.1 200 OK
# X-RateLimit-Limit: 120
# X-RateLimit-Remaining: 119 Now create a ticket. Contact, subject, opening message and priority all go in one request — there is no separate call for the first message, and could not be: the messages endpoint needs a ticket id in its path.
curl -sS -X POST "$API/workspaces/$WS/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"contact": { "name": "Import Test", "email": "[email protected]" },
"subject": "[import-test] Refund request for order #1042",
"description": "Hi, I was charged twice for order #1042. Can you refund the duplicate charge?",
"priority": "medium"
}'
# HTTP/1.1 201 Created
# {"data":{"workspace_id":"3","contact_id":552,
# "subject":"Ticket #321 - [import-test] Refund request for order #1042",
# "description":"Hi, I was charged twice for order #1042...",
# "priority":"medium","id":321,
# "created_at":"2026-08-12T23:40:37.000000Z"}} Two things about that body will cost you a debugging session if you skip them.
priority is required, and it is not in the published request schema — which documents contact_id and contact and nothing else. subject, description and priority all work anyway; only priority is mandatory. Omit it and you get:
HTTP/1.1 422 Unprocessable Content
{"message":"The priority field is required.",
"errors":{"priority":["The priority field is required."]}} The enum is low, medium, high. If your export has five levels, decide the collapse rule up front. Omitting the contact fails differently — 400 {"message":"Contact ID or Contact JSON is required."} — worth handling separately, since it usually means a blank email column rather than a bad request.
The second thing: description is not just a stored field. It synthesizes the ticket's first message, and that message goes through the mail pipeline. Fetching a fresh ticket's messages returned one with type: "email", sender_type: App\Models\Contact, provider: "ses" and a real provider_message_id. Ticket creation is not a silent database insert — budget for that when you size the run.
Then append the rest of the thread, in export order. message and type are required; message caps at 10,000 characters. There is no sender to choose: every message is attributed to the user the token belongs to. sender_id is still accepted, but only if it is that same user's id — send anyone else's and you get a 422 rather than a reply silently signed by the token owner.
curl -sS -X POST "$API/workspaces/$WS/tickets/321/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": "Thanks for reaching out — I have located order #1042 and issued the refund.",
"type": "email"
}'
# HTTP/1.1 201 Created
# {"data":{"id":348,"ticket_id":"321","type":"email",
# "sender_type":"App\\Models\\User",
# "from_email":"[email protected]",
# "to_email":"[email protected]",
# "in_reply_to":"<[email protected]>"}} Read those response fields before you replay a year of history. from_email, to_email and in_reply_to are populated on an email message — live transport fields, not archival ones. At the transport layer an imported agent reply is indistinguishable from a new one. Run the first pass against a throwaway workspace and a contact domain you own before pointing it at real customer addresses.
The conservative alternative is to import historical replies as internal_note. Notes carry no transport fields at all, so nothing can leave the building. You lose the public/private distinction in exchange for a migration that cannot email a thousand customers at 2am — for threads older than a year, usually the right trade. Label the direction in the body text and move on.
-d '{"message":"Confirmed duplicate charge, refunded via order #1042-R.","type":"internal_note"}' Attachments are a two-step: upload, then reference. POST /workspaces/<ws>/attachments takes one multipart/form-data file per call, capped at 10 MB, and returns id, original_filename, mime_type and size. Pass those ids into attachment_ids on the message create, ten per message maximum. This route is in the API spec but was not exercised in the live run behind this article — treat the shape as spec-accurate and the behaviour as unverified, and test one file before queueing five thousand.
ATT=$(curl -sS -X POST "$API/workspaces/$WS/attachments" \
-H "Authorization: Bearer $TOKEN" \
-F "[email protected]" | jq -r '.data.id')
# then, on the message create:
# {"message":"Invoice attached.","type":"email","attachment_ids":[$ATT]} Idempotency and Rate Limits
There is no external_id, import_id or source_id field anywhere in the ticket schema. That is the single most important fact for planning an import: the API has no server-side idempotency key, so re-running your script on a partially completed batch will happily create the same tickets again. Nothing stops you and nothing warns you.
The fix is a checkpoint file you own. Append one line per imported row — export id, returned ticket id — flush after every write, and skip rows already in it on startup. Do not hold it in memory until the end; the run you need it for is the one that dies at row 3,847.
# sketch — create_ticket and post_messages are your own wrappers around the
# two curl calls above, each ending in "sleep 1" after its own request
while IFS= read -r row; do
src_id=$(printf '%s' "$row" | jq -r '.id')
grep -q "^$src_id," checkpoint.csv && continue
ticket_id=$(create_ticket "$row") # 1 request + sleep 1
post_messages "$ticket_id" "$row" # N requests + sleep 1 each
printf '%s,%s\n' "$src_id" "$ticket_id" >> checkpoint.csv
done < tickets.ndjson For a server-side check, the list endpoint filters on contact_id, status, priority, tag, agent_id, date_from and date_to, among others, and paginates. Asking "what does this contact already have" by contact_id is a sound safety net, though you still match on subject client-side. A search parameter is documented too, but what it matches was not verified here — do not make it your only dedup check.
On rate limits, follow what is observable. Every authenticated response carried X-RateLimit-Limit: 120 and an X-RateLimit-Remaining counter. That counter is not a simple per-run budget: across one session it fell from 119 to 113, jumped back to 119, then resumed falling. The shape suggests a rolling window that refills, but the window was never measured — do not encode a guess about it. No X-RateLimit-Reset or Retry-After header appeared on any response, and nothing in that session was throttled, so nobody can tell you what a 429 looks like here. Handle it defensively: read X-RateLimit-Remaining, pause below a floor you pick, and back off exponentially on any non-2xx rather than parsing a header that may not arrive. Sleep per request, not per row — a row is at least two calls, so a per-row sleep runs at double the rate you think it does. One second per request holds you at 60 a minute, half the observed ceiling, and turns a 2,000-ticket import with two messages each — 6,000 calls — into about 100 minutes. Cheap insurance for a job that runs once.
Verify and Roll Back
An import you cannot undo is a decision, not a migration. Verify in three passes.
Count. Checkpoint file lines against export rows. They must match exactly; a shortfall is rows that errored, and those should already be in a rejects file.
Server-side count. Query the list endpoint by contact_id and compare. List items include messages_count — handy, and absent from the single-ticket response, so read it here.
curl -sS "$API/workspaces/$WS/tickets?contact_id=552" -H "Authorization: Bearer $TOKEN"
# {"current_page":1,"data":[
# {"id":321,"has_draft":null,"messages_count":3},
# {"id":322,"has_draft":null,"messages_count":1},
# ...],"total":4} Spot-check threading. Open the five deepest threads and read them. Counts confirm nothing about order, and out-of-order messages are the defect that survives every automated check.
Rollback is the bulk endpoint, fed from the checkpoint file, 100 ids at a time: trash first, then force_delete. Both ran back to back in one session with no waiting period and no separate confirmation, each returning {"data":{"affected":4}}; afterwards every id returned 404. Contacts created as a side effect are separate — DELETE /workspaces/<ws>/contacts/<id> returns 204 and does not cascade from the ticket delete.
curl -sS -X POST "$API/workspaces/$WS/tickets/bulk" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"action":"trash","ids":[319,320,321,322]}'
# {"data":{"affected":4}} The same endpoint does your post-import tidying: close the batch that was closed in the source, and add_tag with a tag_id so the whole import stays filterable by tag forever after.
GoPimi is an API-first ticket system — everything above is the public API, not an admin backdoor, which is also why its limits are easy to check before you commit to a migration. Start with the getting started guide, browse the API reference, or see the same endpoints from the other direction in integrating a helpdesk API into your product.