FieldCamp

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.

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:

FilterTypePurpose
searchstringFree-text match across job number, title, and notes — ideal for jobNumber.
clientIdstringNarrow to one client (works great combined with search).
statusstringFilter by job status (e.g. scheduled, in_progress, completed).
sinceISO dateOnly jobs created or updated after this timestamp.
limitintegerPage 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:

What about GET, PUT, and DELETE?

  • GET is 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:

  1. Your jobNumber includes a timestamp or random value, so each retry computes a different key.
  2. You're filtering client-side after a GET /api/jobs that's paginated, and the first page didn't contain the existing job. Switch to GET /api/v1/jobs?search={jobNumber}.
  3. Two parallel workers hit POST at 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.

On this page