Freshdesk API with Python

Thirty lines of Freshdesk API Python gets you talking. The next thirty are what stop your script dying at 2am on a rate limit.

The smallest Freshdesk API Python script that works

You need requests and your key. Auth is a tuple, because basic auth takes the key as the username and any string as the password, as covered in the API key guide.

import os, requests
DOMAIN = os.environ["FD_DOMAIN"]  # e.g. "acme" for acme.freshdesk.com
AUTH = (os.environ["FD_API_KEY"], "X")
BASE = f"https://{DOMAIN}.freshdesk.com/api/v2"
r = requests.get(f"{BASE}/tickets", auth=AUTH, timeout=30)
r.raise_for_status()
print(len(r.json()))

Key from the environment, never from source. A committed key stays in the git history after you delete the line, and it carries every permission the owning agent has.

Set a timeout on every call. requests has no default one, and a hung socket in a nightly job is the most boring outage there is.

A session, and one place for errors

Use a Session so the TCP connection is reused across a few thousand calls, and put every request through one function. Retry logic then lives in exactly one place.

S = requests.Session()
S.auth = AUTH
S.headers.update({"Content-Type": "application/json"})

Then the wrapper, which is where the real work is.

def call(method, path, **kw):
    for attempt in range(6):
        r = S.request(method, f"{BASE}{path}", timeout=30, **kw)
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", 60))
            time.sleep(wait + 1)
            continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt)
            continue
        if not r.ok:
            raise RuntimeError(f"{r.status_code} {r.text}")
        return r
    raise RuntimeError("gave up after retries")

Three deliberate choices in there. Retry-After is honoured rather than guessed, because Freshdesk tells you exactly how long the window has left and a fixed sleep either wastes time or burns the next window. Server errors get exponential backoff, since those are usually transient. And a 4xx that isn't a 429 raises straight away with the response body attached, because a 400 won't fix itself and the body names the offending field.

Paginating without lying to yourself

The instinct is to increment a page counter until you get a short page. Don't. Follow the Link header, which is the API telling you whether more exists.

def paginate(path, params=None):
    params = dict(params or {})
    params.setdefault("per_page", 100)
    url = path
    while url:
        r = call("GET", url, params=params)
        for item in r.json():
            yield item
        nxt = r.links.get("next")
        url = nxt["url"].replace(BASE, "") if nxt else None
        params = None  # the next link already carries them

requests parses the Link header into r.links for you, which is why it's short. Yielding rather than accumulating matters too: a generator lets you stream a hundred thousand tickets through a writer without holding them in memory.

One trap. Once you follow a next URL, it already contains the query string, so passing your original params again will either duplicate them or reset the page. Clear them after the first call.

Windowing for a real export

Deep pagination is capped, so you can't walk an entire multi-year queue from page one. Slice by time instead and page inside each slice.

def tickets_since(start, days=7):
    cursor = start
    while cursor < datetime.now(timezone.utc):
        end = cursor + timedelta(days=days)
        yield from paginate("/tickets", {
            "updated_since": cursor.isoformat(),
            "order_by": "updated_at", "order_type": "asc",
        })
        cursor = end

Narrow the window if a slice ever hits the page cap. A busy queue may need a day at a time, a quiet one can take a month.

Persist the cursor after each successful window, not at the end of the whole run. Then a crash at hour six resumes at hour six instead of starting again. Overlap the next window by a couple of minutes too, so nothing falls through the boundary.

Writing, and the things that bite

Creates and updates go through the same wrapper with json= rather than data=.

call("PUT", f"/tickets/{tid}", json={"priority": 3})

Send only what you're changing. Updates are partial, and posting the whole object back overwrites whatever an agent edited while you were thinking.

Four things that catch Python developers specifically.

Custom fields are a nested dict with account-specific machine names. Fetch /ticket_fields once at startup and build the mapping, rather than hardcoding a name you read off a screenshot.
Attachments are multipart. That one call uses files= and must not send the JSON content type header, so it bypasses your session default. It'll look like the odd one out because it is.
Timestamps are UTC. Use timezone-aware datetimes throughout. Naive datetimes are how you get an off-by-one-hour sync that only breaks in summer.
Log the response body on failure, not just the status. Freshdesk validation errors name the field. Throwing that away turns a two-minute fix into an afternoon.
FAQ

Frequently asked questions

Is there a Python client worth using?

Community ones exist and requests is usually enough. A Freshdesk Python example built on a session object gives you retries and headers in one place, which is most of what a python Freshdesk client does anyway.

Is there an official Freshdesk API Python library?

Nothing official worth depending on for the REST API. requests plus about sixty lines gives you a client you understand, and unmaintained third-party wrappers tend to lag behind the API.

How do I authenticate in Python?

Pass auth as a two-tuple of your API key and any string, conventionally "X". requests builds the basic auth header from that. Load the key from an environment variable.

How should I handle a 429?

Read the Retry-After header, sleep for that many seconds plus a small buffer, and retry the same request. Never retry immediately, and never use a fixed sleep instead of the header.

How do I export more tickets than pagination allows?

Slice by time using updated_since, page through each window following the Link header, then advance the window. Persist the cursor after each window so a crash resumes rather than restarts.

Why do my custom field updates fail with a 400?

Almost always the wrong machine name. Custom field keys are account-specific, and they're not the labels you see in the interface. Read them from the ticket fields endpoint and log the error body, which names the field.

Before you script deduplication

Matching duplicate tickets well is a harder problem than it looks. Ticket Merger does it on Freshdesk and Zendesk, from $29/month, with a 14-day trial and no card.

Start free trial

14-day free trial. No credit card required.