Idempotency | FieldCamp API
Make FieldCamp API retries safe with idempotent create patterns using server-side filters on the v1 endpoints.
The FieldCamp v1 API does not yet implement server-side idempotency keys, so it's up to the caller to make creates safe to retry. The recommended idempotency pattern for FieldCamp is to generate a deterministic, unique jobNumber (or equivalent business key) and use the server-side filter parameters on GET /api/v1/jobs to look up an existing record before issuing a POST.
This page covers the JSON-based v1 surface, the server-side filters that make idempotent lookups cheap, and the patterns to use for clients, items, and other create endpoints. If you are still calling the legacy multipart POST /api/jobs route, migrate to the v1 JSON endpoints — the new shape is far easier to retry safely. New to the API? Start with the API quickstart and authentication guides.
v1 endpoints accept and return JSON. The older POST /api/jobs route that used multipart/form-data is being retired. New integrations should use POST /api/v1/jobs with a JSON body and the matching GET /api/v1/jobs filters described below.
Why retries can create duplicates
Network blips, gateway timeouts, and webhook redelivery all mean the same logical "create job" call can hit FieldCamp more than once. Without a stable business key and a pre-flight lookup, each retry produces a brand-new job, a duplicate invoice line, or a duplicate client. The cost shows up downstream — in dispatch (AI Dispatcher sees two jobs, your team double-rolls a truck, the customer gets two confirmation texts).
The fix is a deterministic key plus a server-side filter check before every create. With release_v2 of the v1 API, this is now a single GET call instead of a paginated scan. See the API changelog for the full release_v2 surface.
Recommended pattern (v1)
Pick a deterministic business key
Choose a jobNumber format that is uniquely derived from your source system — for example ACME-{bookingId}, WC-{orderId}, or ZD-{ticketId}-MOVE. Never include timestamps or randomness in the key itself.
Look up by jobNumber using server-side filters
Call GET /api/v1/jobs?search={jobNumber} (or filter by clientId if you have one) so the server does the work. Do not list all jobs and scan client-side — that doesn't scale.
If a match exists, return it
If the response contains a job with a matching jobNumber, you're done. Return the existing id to your caller and skip the create.
Otherwise, POST the new job
Send POST /api/v1/jobs with a JSON body that includes your deterministic jobNumber. If the create succeeds, store the returned id so future retries can short-circuit even before the lookup.
Record the mapping on your side
Persist {bookingId → fieldcampJobId} in your own database. The next retry can skip both the lookup and the create.
Reference implementation
async function findJobByNumber(jobNumber) {
// Server-side filter — no client-side scan needed.
const { data } = await fc.get('/api/v1/jobs', {
params: { search: jobNumber, limit: 1 },
});
const match = (data.data?.jobs ?? data.jobs ?? []).find(
(j) => j.jobNumber === jobNumber,
);
return match?.id ?? null;
}
async function createJobIdempotently(jobPayload) {
// 1. Pre-flight: does it already exist?
const existingId = await findJobByNumber(jobPayload.jobNumber);
if (existingId) return existingId;
// 2. Create with a JSON body (v1 surface).
const { data } = await fc.post('/api/v1/jobs', jobPayload, {
headers: { 'Content-Type': 'application/json' },
});
return data.data?.id ?? data.id;
}If your jobs are linked to a known client, pass clientId along with search to narrow the result set further. Combined with status and since, these filters keep the lookup fast even for high-volume integrations. Read the full filter list on the Jobs resource page.
Available server-side filters on GET /api/v1/jobs
release_v2 exposes filters that replace the old "list everything and grep" pattern:
| Filter | Type | Purpose |
|---|---|---|
search | string | Free-text match across job number, title, and notes — ideal for jobNumber. |
clientId | string | Narrow to one client (works great combined with search). |
status | string | Filter by job status (e.g. scheduled, in_progress, completed). |
since | ISO date | Only jobs created or updated after this timestamp. |
limit | integer | Page size (default 50). Use limit=1 for pure existence checks. |
Combine search={jobNumber}&clientId={cid}&limit=1 for the tightest, cheapest existence check. Combining filters also helps you stay under the API rate limits by cutting unnecessary scans.
Naming your jobNumber
A good jobNumber is:
- Unique per real-world unit of work. A booking ID, an order number, a ticket ID — something the source system already guarantees is unique.
- Prefixed with your system name.
ACME-,BOOKING-,WC-makes the origin obvious in the FieldCamp UI and the client detail page. - Suffixed by kind if you create more than one job per source record.
BOOKING-5512-MOVE,BOOKING-5512-RETURN. - Stable across retries. Never include a timestamp, UUID, or random suffix — those defeat dedup. Generate the key once, then reuse it on every retry.
Do not use Date.now() or crypto.randomUUID() to build a jobNumber. Those values change on every retry, which means every retry is a "new" job from the API's perspective and you'll create duplicates.
Patterns for other resources
The same lookup-then-create pattern applies to any v1 create endpoint:
Use GET /api/v1/clients?search={email} before POST /api/v1/clients. Email is the natural dedup key.
Use GET /api/v1/items?search={name} to check for a matching product or service before POST.
Visits inherit dedup behavior from their parent job — if the job lookup short-circuits, you won't create stray visits.
Submissions are tied to a visit; idempotency at the visit level cascades.
What about GET, PUT, and DELETE?
GETis naturally idempotent — retrying it has no side effects.PUT /api/v1/jobs/{id}is a partial update on a known resource, so the same body twice yields the same state. Retries are safe.DELETE /api/v1/jobs/{id}is idempotent in result: deleting twice yields the same final state (the record is gone). A 404 on the second call is expected, not an error worth alerting on. See errors and retries for status-code guidance.
Only POST creates need the extra care described above.
Webhook retries
Webhooks follow the same model as inbound API calls — FieldCamp will retry delivery if your endpoint returns a non-2xx or times out. Treat the webhook id (or event_id) as your dedup key on the receiving side: write it to a processed_events table with a unique constraint and short-circuit on conflict. That keeps your downstream effects (sending a Slack message, posting back to FieldCamp, billing the customer) firing exactly once. The full list of payload shapes lives in the webhook events catalog.
Migrating from the legacy POST /api/jobs
If your integration still posts multipart form data to POST /api/jobs:
Switch to JSON
Replace FormData with a plain JSON object. The v1 endpoint takes the same fields — jobNumber, clientId, title, description, visits, etc. — in a single JSON body.
Update the URL
Change /api/jobs to /api/v1/jobs. All v1 endpoints live under /api/v1/.
Update the lookup
Replace the old findJobByNumber (which paged through all jobs) with the server-side search filter shown above.
Re-test idempotency
Replay a known-good create twice in a row. The second call should return the same id without creating a duplicate.
The legacy multipart route still works for now, but new features (like richer error responses and the since filter) only land on the v1 surface. See the API changelog for the current deprecation timeline.
Troubleshooting
Before debugging idempotency issues, confirm you're using the v1 surface. Look at the request URL — it should start with /api/v1/, not bare /api/.
My retry created a duplicate anyway
Usually one of three things:
- Your
jobNumberincludes a timestamp or random value, so each retry computes a different key. - You're filtering client-side after a
GET /api/jobsthat's paginated, and the first page didn't contain the existing job. Switch toGET /api/v1/jobs?search={jobNumber}. - Two parallel workers hit
POSTat the same time and both passed the lookup before either created. Add a brief lock on{source-system, jobNumber}in your code, or accept the rare duplicate and reconcile via a daily sweep.
My lookup returns the wrong job
The search filter does a fuzzy match across job number, title, and notes. If two of your jobNumber values share a prefix (e.g. BOOKING-55 and BOOKING-551), filter the response further in code on exact jobNumber equality — exactly what the reference implementation above does.
How do I dedup clients without an email?
Use the most stable identifier you have — phone number, your CRM ID, or a combination. Many teams set a custom client field like externalId and filter on that. The lookup logic is identical.
Are idempotency keys on the roadmap?
Yes. Server-side Idempotency-Key headers (compatible with the common Stripe/Plaid pattern) are planned. When they ship, the patterns on this page will keep working — the header will simply give you a backstop if your business key ever drifts.
Does the AI Dispatcher dedup automatically?
No. The dispatcher operates on the jobs it sees. If you double-post, the dispatcher will plan both — so dedup at the API boundary, before jobs hit the Job Tray and dispatch view in the calendar.
Related articles
Errors & Retries | FieldCamp API
Response envelope format, HTTP status codes, and retry guidance for the FieldCamp API.
FieldCamp API Rate Limits and Throttling Headers
Understand FieldCamp API rate limits, the 60 RPM sliding window, throttling headers, and how to handle 429 errors in production integrations.