Skip to content

Jobs API ​

Requests, quotes, projects or tickets on the site's pipeline: create them from your own forms and systems, move them through the stages, read their history. Part of the API reference: setup, shared types and errors are there.

List jobs ​

GET /jobs · scope jobs:read

In board order, up to 500, with each stage's count and total value. Open jobs by default.

ParameterInRequiredDescription
statusquerynoopen (default), won, lost or all.
stagequerynoOne stage's id.
assignedquerynome, none, or a team member's email.
sourcequerynochat, quote, api, manual or callback.
contactquerynoOne contact's jobs (their lead id).
conversationquerynoThe jobs that came from one conversation.
qquerynoSearch title, details, fields, the contact, or a number (1042).
sitequerynoThe site (a key always uses its own).

Request

bash
curl "$HELPPUFF_URL/api/v1/jobs" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ListJobsResponse;

Response 200

json
{
  "items": [
    {
      "id": "job_7c2e9a41b5d03f68",
      "number": 1042,
      "title": "Blocked kitchen drain for Ada Lovelace",
      "details": "Kitchen sink drains slowly and gurgles.",
      "stage": {
        "id": "stg_3f2a1b0c9d8e7f6a",
        "name": "New request",
        "kind": "open"
      },
      "status": "open",
      "fields": {
        "service": "Blocked drains",
        "address": "Balmain",
        "urgency": "This week"
      },
      "contact": {
        "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
        "name": "Ada Lovelace",
        "email": "ada@example.com",
        "phone": "0400 111 222"
      },
      "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
      "source": "api",
      "value": 450,
      "valueCents": 45000,
      "currency": "AUD",
      "dueAt": null,
      "assignedTo": "sam@acme.example",
      "assignedName": "Sam",
      "position": 0,
      "stale": false,
      "stageChangedAt": 1760000000000,
      "closedAt": null,
      "lostReason": null,
      "createdAt": 1760000000000,
      "updatedAt": 1760000000000
    }
  ],
  "stages": [
    {
      "id": "stg_3f2a1b0c9d8e7f6a",
      "name": "New request",
      "color": "#3b82f6",
      "position": 0,
      "kind": "open",
      "rotDays": 1,
      "count": 3,
      "valueCents": 135000
    }
  ]
}
ts
type ListJobsResponse = {
  items: Array<{
    id: string;
    number: number;
    title: string;
    details: string | null;
    stage: {
      id: string;
      name: string;
      kind: string;
    };
    status: string;
    fields: Record<string, string>;
    contact: Record<string, string> | null;
    conversationId: string | null;
    source: string;
    value: number | null;
    valueCents: number | null;
    currency: string | null;
    dueAt: number | null;
    assignedTo: string | null;
    assignedName: string | null;
    position: number;
    stale: boolean;
    stageChangedAt: number;
    closedAt: number | null;
    lostReason: string | null;
    createdAt: number;
    updatedAt: number;
  }>;
  stages: Array<{
    id: string;
    name: string;
    color: string;
    position: number;
    kind: string;
    rotDays: number;
    count: number;
    valueCents: number | null;
  }>;
};

Create a job ​

POST /jobs · scope jobs:write

From your own form, CRM or automation (Zapier, Make). The contact is matched by email, then phone, or created. fields are by field name and checked against the site's fields (GET /jobs/pipeline): an unknown field or a value that is not one of a choice's options is refused, a missing required field is not (the team fills it in). Lands in the first stage unless stageId says otherwise. Sends job.created.

Body fieldRequiredDescription
titlenoUp to 160 characters. Left out: made from the fields and the contact.
detailsnoWhat is needed, up to 10,000 characters.
fieldsnoValues by field name, e.g. { "service": "Blocked drains" }. A select field takes one of its options.
contactno{ name, email, phone }: matched by email, then phone, or created.
contactIdnoAn existing contact's id, instead of contact.
conversationIdnoThe conversation it came from.
callbackIdnoA callback request to turn into a job (its contact, conversation and reason).
stageIdnoThe stage to start in.
valuenoWhat it is worth, e.g. 450 (in currency).
currencynoISO 4217, e.g. AUD.
dueAtnoISO 8601 date, or milliseconds.
assignedTonoA team member's email.

Request

bash
curl -X POST "$HELPPUFF_URL/api/v1/jobs" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "title": "Blocked kitchen drain",
  "details": "Kitchen sink drains slowly and gurgles.",
  "fields": {
    "service": "Blocked drains",
    "address": "Balmain",
    "urgency": "This week"
  },
  "contact": {
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "phone": "0400 111 222"
  },
  "value": 450,
  "currency": "AUD"
}'
ts
type CreateJobRequest = {
  /** Up to 160 characters. Left out: made from the fields and the contact. */
  title?: string;
  /** What is needed, up to 10,000 characters. */
  details?: string;
  /**
   * Values by field name, e.g. `{ "service": "Blocked drains" }`. A select
   * field takes one of its options.
   */
  fields?: Record<string, string>;
  /** `{ name, email, phone }`: matched by email, then phone, or created. */
  contact?: Record<string, string>;
  /** An existing contact's id, instead of `contact`. */
  contactId?: string;
  /** The conversation it came from. */
  conversationId?: string;
  /**
   * A callback request to turn into a job (its contact, conversation and
   * reason).
   */
  callbackId?: string;
  /** The stage to start in. */
  stageId?: string;
  /** What it is worth, e.g. `450` (in `currency`). */
  value?: number;
  /** ISO 4217, e.g. `AUD`. */
  currency?: string;
  /** ISO 8601 date, or milliseconds. */
  dueAt?: string;
  /** A team member's email. */
  assignedTo?: string;
};

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: 'Blocked kitchen drain',
    details: 'Kitchen sink drains slowly and gurgles.',
    fields: {
      service: 'Blocked drains',
      address: 'Balmain',
      urgency: 'This week'
    },
    contact: {
      name: 'Ada Lovelace',
      email: 'ada@example.com',
      phone: '0400 111 222'
    },
    value: 450,
    currency: 'AUD'
  } satisfies CreateJobRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as CreateJobResponse;

Response 201

json
{
  "id": "job_7c2e9a41b5d03f68",
  "number": 1042,
  "title": "Blocked kitchen drain for Ada Lovelace",
  "details": "Kitchen sink drains slowly and gurgles.",
  "stage": {
    "id": "stg_3f2a1b0c9d8e7f6a",
    "name": "New request",
    "kind": "open"
  },
  "status": "open",
  "fields": {
    "service": "Blocked drains",
    "address": "Balmain",
    "urgency": "This week"
  },
  "contact": {
    "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 450,
  "valueCents": 45000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "sam@acme.example",
  "assignedName": "Sam",
  "position": 0,
  "stale": false,
  "stageChangedAt": 1760000000000,
  "closedAt": null,
  "lostReason": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
ts
type CreateJobResponse = {
  id: string;
  number: number;
  title: string;
  details: string | null;
  stage: {
    id: string;
    name: string;
    kind: string;
  };
  status: string;
  fields: Record<string, string>;
  contact: Record<string, string> | null;
  conversationId: string | null;
  source: string;
  value: number | null;
  valueCents: number | null;
  currency: string | null;
  dueAt: number | null;
  assignedTo: string | null;
  assignedName: string | null;
  position: number;
  stale: boolean;
  stageChangedAt: number;
  closedAt: number | null;
  lostReason: string | null;
  createdAt: number;
  updatedAt: number;
};

Errors

StatusCodeWhen
400bad_requestAn unknown field, a value not among a select field's options, or a bad email.

Get a job ​

GET /jobs/:id · scope jobs:read

The job with its history (every change, with who and when), notes, and the conversation it came from.

ParameterInRequiredDescription
idpathyesThe record's id.

Request

bash
curl "$HELPPUFF_URL/api/v1/jobs/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as GetJobResponse;

Response 200

json
{
  "id": "job_7c2e9a41b5d03f68",
  "number": 1042,
  "title": "Blocked kitchen drain for Ada Lovelace",
  "details": "Kitchen sink drains slowly and gurgles.",
  "stage": {
    "id": "stg_3f2a1b0c9d8e7f6a",
    "name": "New request",
    "kind": "open"
  },
  "status": "open",
  "fields": {
    "service": "Blocked drains",
    "address": "Balmain",
    "urgency": "This week"
  },
  "contact": {
    "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 450,
  "valueCents": 45000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "sam@acme.example",
  "assignedName": "Sam",
  "position": 0,
  "stale": false,
  "stageChangedAt": 1760000000000,
  "closedAt": null,
  "lostReason": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000,
  "history": [
    {
      "id": "evt_9b1c2d3e4f5a6b7c",
      "at": 1760000000000,
      "actor": "api",
      "actorName": "Website backend",
      "kind": "created",
      "data": {
        "source": "api",
        "stage": "New request"
      }
    }
  ],
  "notes": [
    {
      "id": "note_mfx2k1a9",
      "author": "sam@acme.example",
      "authorName": "Sam",
      "text": "Called, booking Tuesday.",
      "createdAt": 1760000000000,
      "updatedAt": 1760000000000
    }
  ],
  "conversation": {
    "id": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
    "firstMessage": "My kitchen drain is blocked",
    "startedAt": 1760000000000
  }
}
ts
type GetJobResponse = {
  id: string;
  number: number;
  title: string;
  details: string | null;
  stage: {
    id: string;
    name: string;
    kind: string;
  };
  status: string;
  fields: Record<string, string>;
  contact: Record<string, string> | null;
  conversationId: string | null;
  source: string;
  value: number | null;
  valueCents: number | null;
  currency: string | null;
  dueAt: number | null;
  assignedTo: string | null;
  assignedName: string | null;
  position: number;
  stale: boolean;
  stageChangedAt: number;
  closedAt: number | null;
  lostReason: string | null;
  createdAt: number;
  updatedAt: number;
  history: Array<{
    id: string;
    at: number;
    actor: string;
    actorName: string;
    kind: string;
    data: {
      source: string;
      stage: string;
    };
  }>;
  notes: Array<{
    id: string;
    author: string;
    authorName: string;
    text: string;
    createdAt: number;
    updatedAt: number;
  }>;
  conversation: {
    id: string;
    firstMessage: string;
    startedAt: number;
  } | null;
};

Change a job ​

PATCH /jobs/:id · scope jobs:write

Title, details, fields (merged: null clears one), value, currency, due date, assignee. Each change goes into the history. Sends job.updated.

ParameterInRequiredDescription
idpathyesThe record's id.
Body fieldRequiredDescription
titlenoUp to 160 characters.
detailsnoReplaces the details. To add to them, use POST /jobs/:id/updates.
fieldsnoValues by field name; null clears one.
valuenoA number, or null.
currencynoISO 4217.
dueAtnoISO 8601 date, milliseconds, or null.
assignedTonoA team member's email, or null.

Request

bash
curl -X PATCH "$HELPPUFF_URL/api/v1/jobs/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "fields": {
    "urgency": "Emergency"
  },
  "value": 520
}'
ts
type ChangeJobRequest = {
  /** Up to 160 characters. */
  title?: string;
  /** Replaces the details. To add to them, use `POST /jobs/:id/updates`. */
  details?: string;
  /** Values by field name; `null` clears one. */
  fields?: Record<string, string>;
  /** A number, or null. */
  value?: number;
  /** ISO 4217. */
  currency?: string;
  /** ISO 8601 date, milliseconds, or null. */
  dueAt?: string;
  /** A team member's email, or null. */
  assignedTo?: string;
};

const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    fields: {
      urgency: 'Emergency'
    },
    value: 520
  } satisfies ChangeJobRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ChangeJobResponse;

Response 200

json
{
  "id": "job_7c2e9a41b5d03f68",
  "number": 1042,
  "title": "Blocked kitchen drain for Ada Lovelace",
  "details": "Kitchen sink drains slowly and gurgles.",
  "stage": {
    "id": "stg_3f2a1b0c9d8e7f6a",
    "name": "New request",
    "kind": "open"
  },
  "status": "open",
  "fields": {
    "service": "Blocked drains",
    "address": "Balmain",
    "urgency": "Emergency"
  },
  "contact": {
    "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 520,
  "valueCents": 52000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "sam@acme.example",
  "assignedName": "Sam",
  "position": 0,
  "stale": false,
  "stageChangedAt": 1760000000000,
  "closedAt": null,
  "lostReason": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
ts
type ChangeJobResponse = {
  id: string;
  number: number;
  title: string;
  details: string | null;
  stage: {
    id: string;
    name: string;
    kind: string;
  };
  status: string;
  fields: Record<string, string>;
  contact: Record<string, string> | null;
  conversationId: string | null;
  source: string;
  value: number | null;
  valueCents: number | null;
  currency: string | null;
  dueAt: number | null;
  assignedTo: string | null;
  assignedName: string | null;
  position: number;
  stale: boolean;
  stageChangedAt: number;
  closedAt: number | null;
  lostReason: string | null;
  createdAt: number;
  updatedAt: number;
};

Move a job ​

POST /jobs/:id/move · scope jobs:write

To another stage (a won stage closes it and makes the contact won; a lost one closes it, with lostReason), and/or another place in its column (before: the job it now sits above; null: the bottom). Sends job.stage_changed, then job.won or job.lost.

ParameterInRequiredDescription
idpathyesThe record's id.
Body fieldRequiredDescription
stageIdnoThe stage to move it to.
beforenoThe id of the job it now sits above, or null for the bottom.
lostReasonnoWhy, when moving to a lost stage.

Request

bash
curl -X POST "$HELPPUFF_URL/api/v1/jobs/ID/move" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "stageId": "stg_8d7c6b5a4f3e2d1c"
}'
ts
type MoveJobRequest = {
  /** The stage to move it to. */
  stageId?: string;
  /** The id of the job it now sits above, or null for the bottom. */
  before?: string;
  /** Why, when moving to a lost stage. */
  lostReason?: string;
};

const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}/move`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    stageId: 'stg_8d7c6b5a4f3e2d1c'
  } satisfies MoveJobRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as MoveJobResponse;

Response 200

json
{
  "id": "job_7c2e9a41b5d03f68",
  "number": 1042,
  "title": "Blocked kitchen drain for Ada Lovelace",
  "details": "Kitchen sink drains slowly and gurgles.",
  "stage": {
    "id": "stg_8d7c6b5a4f3e2d1c",
    "name": "Quote sent",
    "kind": "open"
  },
  "status": "open",
  "fields": {
    "service": "Blocked drains",
    "address": "Balmain",
    "urgency": "This week"
  },
  "contact": {
    "id": "lead_5e3f0c8a-1f7b-4f8e-9a51-0e2b7c4d1a90",
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 450,
  "valueCents": 45000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "sam@acme.example",
  "assignedName": "Sam",
  "position": 0,
  "stale": false,
  "stageChangedAt": 1760000000000,
  "closedAt": null,
  "lostReason": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
ts
type MoveJobResponse = {
  id: string;
  number: number;
  title: string;
  details: string | null;
  stage: {
    id: string;
    name: string;
    kind: string;
  };
  status: string;
  fields: Record<string, string>;
  contact: Record<string, string> | null;
  conversationId: string | null;
  source: string;
  value: number | null;
  valueCents: number | null;
  currency: string | null;
  dueAt: number | null;
  assignedTo: string | null;
  assignedName: string | null;
  position: number;
  stale: boolean;
  stageChangedAt: number;
  closedAt: number | null;
  lostReason: string | null;
  createdAt: number;
  updatedAt: number;
};

Add an update ​

POST /jobs/:id/updates · scope jobs:write

A dated update in the job's history ("Parts ordered, back Thursday"). The details stay as they were.

ParameterInRequiredDescription
idpathyesThe record's id.
Body fieldRequiredDescription
textyesUp to 4000 characters.

Request

bash
curl -X POST "$HELPPUFF_URL/api/v1/jobs/ID/updates" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "text": "Parts ordered, back Thursday."
}'
ts
type AddUpdateRequest = {
  /** Up to 4000 characters. */
  text: string;
};

const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}/updates`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'Parts ordered, back Thursday.'
  } satisfies AddUpdateRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as AddUpdateResponse;

Response 201

json
{
  "id": "job_7c2e9a41b5d03f68",
  "added": true
}
ts
type AddUpdateResponse = {
  id: string;
  added: boolean;
};

Add a note to a job ​

POST /jobs/:id/notes · scope jobs:write

A private note for the team.

ParameterInRequiredDescription
idpathyesThe record's id.
Body fieldRequiredDescription
textyesUp to 5000 characters.

Request

bash
curl -X POST "$HELPPUFF_URL/api/v1/jobs/ID/notes" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "text": "Gate code 4471."
}'
ts
type AddNoteToJobRequest = {
  /** Up to 5000 characters. */
  text: string;
};

const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}/notes`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'Gate code 4471.'
  } satisfies AddNoteToJobRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as AddNoteToJobResponse;

Response 201

json
{
  "id": "note_mfx2k1a9",
  "jobId": "job_7c2e9a41b5d03f68",
  "author": "key:k7m3p9q2r4s8",
  "authorName": "Website backend",
  "text": "Gate code 4471.",
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
ts
type AddNoteToJobResponse = {
  id: string;
  jobId: string;
  author: string;
  authorName: string | null;
  text: string;
  createdAt: number;
  updatedAt: number;
};

A job's files ​

GET /jobs/:id/files · scope jobs:read

The photos and files attached to the job, and (fromChat) the ones the visitor sent in the job's conversation. Each url is a signed link that works for 12 hours: list again for a fresh one. enabled is files.jobs; storage whether the deployment has its R2 bucket.

ParameterInRequiredDescription
idpathyesThe record's id.

Request

bash
curl "$HELPPUFF_URL/api/v1/jobs/ID/files" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}/files`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as JobSFilesResponse;

Response 200

json
{
  "enabled": true,
  "storage": true,
  "maxMb": 25,
  "files": [
    {
      "id": "f_mfx2k1a9b2c3d4e5f6a7",
      "name": "after-install.jpg",
      "type": "image/jpeg",
      "size": 284113,
      "url": "https://helppuff.acme.workers.dev/v1/files/acme/job/f_mfx2k1a9b2c3d4e5f6a7?e=t3k2p0&s=Xk…",
      "createdAt": 1760000000000,
      "uploadedBy": "sam@acme.com",
      "uploadedByName": "Sam"
    }
  ],
  "fromChat": [
    {
      "id": "f_mfx2k0a1b2c3d4e5f6a7",
      "name": "leak.jpg",
      "type": "image/jpeg",
      "size": 182044,
      "url": "https://helppuff.acme.workers.dev/v1/files/acme/chat/f_mfx2k0a1b2c3d4e5f6a7?e=t3k2p0&s=Zq…",
      "uploadedBy": "visitor",
      "uploadedByName": "Sam",
      "createdAt": 1760000000000
    }
  ]
}
ts
type JobSFilesResponse = {
  enabled: boolean;
  storage: boolean;
  maxMb: number;
  files: Array<{
    id: string;
    name: string;
    type: string;
    size: number;
    url: string;
    createdAt: number;
    uploadedBy: string;
    uploadedByName: string | null;
  }>;
  fromChat: Array<{
    id: string;
    name: string;
    type: string;
    size: number;
    url: string;
    uploadedBy: string;
    uploadedByName: string | null;
    createdAt: number;
  }>;
};

Attach a file to a job ​

POST /jobs/:id/files · scope jobs:write

The raw bytes as the body, the file name as ?name=. Photos (JPEG, PNG, WebP, GIF, HEIC), PDF, Word, Excel, PowerPoint, text, CSV, MP4 or MOV, up to files.maxJobMb (25 MB unless changed). The type is read from the bytes. Needs files on jobs on (files.jobs) and the deployment's R2 bucket. Recorded in the job's history.

ParameterInRequiredDescription
idpathyesThe record's id.
namequeryyesThe file name, with its extension.

Request

bash
curl -X POST "$HELPPUFF_URL/api/v1/jobs/ID/files?name=after-install.jpg" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @price-list.pdf
ts
const id = '…';
import { readFile } from 'node:fs/promises';

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}/files?${new URLSearchParams({ name: 'after-install.jpg' })}`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/octet-stream',
  },
  body: await readFile('price-list.pdf'),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as AttachFileToJobResponse;

The body is the file's bytes.

Response 201

json
{
  "id": "f_mfx2k1a9b2c3d4e5f6a7",
  "name": "after-install.jpg",
  "type": "image/jpeg",
  "size": 284113,
  "url": "https://helppuff.acme.workers.dev/v1/files/acme/job/f_mfx2k1a9b2c3d4e5f6a7?e=t3k2p0&s=Xk…",
  "createdAt": 1760000000000,
  "uploadedBy": "key:k7m3p9q2r4s8",
  "uploadedByName": "Website backend"
}
ts
type AttachFileToJobResponse = {
  id: string;
  name: string;
  type: string;
  size: number;
  url: string;
  createdAt: number;
  uploadedBy: string;
  uploadedByName: string | null;
};

Errors

StatusCodeWhen
400bad_requestFiles on jobs are off, the file is empty, too large, or not a kind HelpPuff keeps.
404not_foundNo such job, or the deployment has no file storage (turn on R2, then helppuff deploy).

Remove a file from a job ​

DELETE /jobs/:id/files/:fileId · scope jobs:write

Deletes the file. Recorded in the job's history.

ParameterInRequiredDescription
idpathyesThe record's id.
fileIdpathyesThe fileId.

Request

bash
curl -X DELETE "$HELPPUFF_URL/api/v1/jobs/ID/files/FILEID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
const id = '…';
const fileId = '2';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}/files/FILEID`, {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as RemoveFileFromJobResponse;

Response 200

json
{
  "id": "f_mfx2k1a9b2c3d4e5f6a7",
  "deleted": true
}
ts
type RemoveFileFromJobResponse = {
  id: string;
  deleted: boolean;
};

Delete a job ​

DELETE /jobs/:id · scope jobs:write

With its history and notes. The contact stays.

ParameterInRequiredDescription
idpathyesThe record's id.

Request

bash
curl -X DELETE "$HELPPUFF_URL/api/v1/jobs/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
const id = '…';
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/${id}`, {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as DeleteJobResponse;

Response 200

json
{
  "id": "job_7c2e9a41b5d03f68",
  "deleted": true
}
ts
type DeleteJobResponse = {
  id: string;
  deleted: boolean;
};

The pipeline ​

GET /jobs/pipeline · scope jobs:read

The stages (in order; kind open, won or lost), the fields a job has (and the question asked for each), the quote questions, which template it came from and why, and the templates there are.

ParameterInRequiredDescription
sitequerynoThe site (a key always uses its own).

Request

bash
curl "$HELPPUFF_URL/api/v1/jobs/pipeline" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
ts
const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/pipeline`, {
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
  },
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as PipelineResponse;

Response 200

json
{
  "pipeline": {
    "siteId": "acme",
    "template": "service-quote",
    "itemSingular": "Job",
    "itemPlural": "Jobs",
    "chosenBy": "ai",
    "reason": "A plumbing business with free on-site quotes.",
    "assistantJobs": true,
    "quote": {
      "enabled": true,
      "label": "Get a quote",
      "askContact": true
    },
    "editedAt": null,
    "stages": [
      {
        "id": "stg_3f2a1b0c9d8e7f6a",
        "name": "New request",
        "color": "#3b82f6",
        "position": 0,
        "kind": "open",
        "rotDays": 1
      }
    ],
    "fields": [
      {
        "id": "fld_1a2b3c4d5e6f7a8b",
        "name": "service",
        "label": "Service",
        "type": "select",
        "required": false,
        "options": [
          "Blocked drains",
          "Hot water"
        ],
        "question": "Which service do you need?",
        "position": 0,
        "inQuote": true,
        "quotePosition": 0,
        "archived": false
      }
    ],
    "quotePreview": [
      {
        "field": "service",
        "ask": "Which service do you need?",
        "input": "choice",
        "choices": [
          "Blocked drains",
          "Hot water"
        ]
      }
    ]
  },
  "templates": [
    {
      "id": "service-quote",
      "name": "Service quote",
      "description": "Trades and home or business services that quote before the work.",
      "stages": [
        {
          "name": "New request",
          "kind": "open",
          "color": "#3b82f6",
          "rotDays": 1
        }
      ],
      "fields": [
        {
          "name": "service",
          "label": "Service",
          "type": "select",
          "options": [],
          "question": "Which service do you need?",
          "quote": true
        }
      ]
    }
  ]
}
ts
type PipelineResponse = {
  pipeline: {
    siteId: string;
    template: string;
    itemSingular: string;
    itemPlural: string;
    chosenBy: string;
    reason: string | null;
    assistantJobs: boolean;
    quote: {
      enabled: boolean;
      label: string;
      askContact: boolean;
    };
    editedAt: number | null;
    stages: Array<{
      id: string;
      name: string;
      color: string;
      position: number;
      kind: string;
      rotDays: number;
    }>;
    fields: Array<{
      id: string;
      name: string;
      label: string;
      type: string;
      required: boolean;
      options: string[];
      question: string;
      position: number;
      inQuote: boolean;
      quotePosition: number;
      archived: boolean;
    }>;
    quotePreview: Array<{
      field: string;
      ask: string;
      input: string;
      choices: string[];
    }>;
  };
  templates: Array<{
    id: string;
    name: string;
    description: string;
    stages: Array<{
      name: string;
      kind: string;
      color: string;
      rotDays: number;
    }>;
    fields: Array<{
      name: string;
      label: string;
      type: string;
      options: string[];
      question: string;
      quote: boolean;
    }>;
  }>;
};

Change the pipeline ​

PUT /jobs/pipeline · scope settings:write

stages and fields are the whole lists, in order: an item with an id is changed, one without is added, one left out is removed (a removed stage's jobs go to moveTo[stageId], else the first stage of the same kind; a removed field is archived, and jobs keep its values). Keep at least one open, one won and one lost stage. quote is the quote questions: field names, in order.

Body fieldRequiredDescription
stagesno[{ id?, name, kind: "open"|"won"|"lost", color?, rotDays? }].
fieldsno[{ id?, label, type: "text"|"textarea"|"number"|"date"|"select"|"email"|"tel", required?, options?, question? }].
quotenoField names asked by the widget's quote questions, in order (up to 10).
quoteEnablednoOffer the quote questions in the widget ("Get a quote").
quoteLabelnoThe button's label.
quoteContactnoAlso ask for name, email and phone at the end.
assistantJobsnoLet the assistant create jobs from the chat.
itemSingularnoWhat a job is called, e.g. "Ticket".
itemPluralnoIts plural.
moveTono{ <removed stage id>: <stage id> }.

Request

bash
curl -X PUT "$HELPPUFF_URL/api/v1/jobs/pipeline" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "quote": [
    "service",
    "description",
    "address"
  ],
  "quoteLabel": "Get a free quote"
}'
ts
type ChangePipelineRequest = {
  /** `[{ id?, name, kind: "open"|"won"|"lost", color?, rotDays? }]`. */
  stages?: string;
  /**
   * `[{ id?, label, type:
   * "text"|"textarea"|"number"|"date"|"select"|"email"|"tel", required?,
   * options?, question? }]`.
   */
  fields?: string;
  /** Field names asked by the widget's quote questions, in order (up to 10). */
  quote?: string[];
  /** Offer the quote questions in the widget ("Get a quote"). */
  quoteEnabled?: string;
  /** The button's label. */
  quoteLabel?: string;
  /** Also ask for name, email and phone at the end. */
  quoteContact?: string;
  /** Let the assistant create jobs from the chat. */
  assistantJobs?: string;
  /** What a job is called, e.g. "Ticket". */
  itemSingular?: string;
  /** Its plural. */
  itemPlural?: string;
  /** `{ <removed stage id>: <stage id> }`. */
  moveTo?: string;
};

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/pipeline`, {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    quote: [
      'service',
      'description',
      'address'
    ],
    quoteLabel: 'Get a free quote'
  } satisfies ChangePipelineRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as ChangePipelineResponse;

Response 200

json
{
  "pipeline": {
    "siteId": "acme",
    "template": "service-quote",
    "itemSingular": "Job",
    "itemPlural": "Jobs",
    "chosenBy": "owner",
    "reason": "A plumbing business with free on-site quotes.",
    "assistantJobs": true,
    "quote": {
      "enabled": true,
      "label": "Get a free quote",
      "askContact": true
    },
    "editedAt": null,
    "stages": [
      {
        "id": "stg_3f2a1b0c9d8e7f6a",
        "name": "New request",
        "color": "#3b82f6",
        "position": 0,
        "kind": "open",
        "rotDays": 1
      }
    ],
    "fields": [
      {
        "id": "fld_1a2b3c4d5e6f7a8b",
        "name": "service",
        "label": "Service",
        "type": "select",
        "required": false,
        "options": [
          "Blocked drains",
          "Hot water"
        ],
        "question": "Which service do you need?",
        "position": 0,
        "inQuote": true,
        "quotePosition": 0,
        "archived": false
      }
    ],
    "quotePreview": [
      {
        "field": "service",
        "ask": "Which service do you need?",
        "input": "choice",
        "choices": [
          "Blocked drains",
          "Hot water"
        ]
      }
    ]
  },
  "templates": [
    {
      "id": "service-quote",
      "name": "Service quote",
      "description": "Trades and home or business services that quote before the work.",
      "stages": [
        {
          "name": "New request",
          "kind": "open",
          "color": "#3b82f6",
          "rotDays": 1
        }
      ],
      "fields": [
        {
          "name": "service",
          "label": "Service",
          "type": "select",
          "options": [],
          "question": "Which service do you need?",
          "quote": true
        }
      ]
    }
  ]
}
ts
type ChangePipelineResponse = {
  pipeline: {
    siteId: string;
    template: string;
    itemSingular: string;
    itemPlural: string;
    chosenBy: string;
    reason: string | null;
    assistantJobs: boolean;
    quote: {
      enabled: boolean;
      label: string;
      askContact: boolean;
    };
    editedAt: number | null;
    stages: Array<{
      id: string;
      name: string;
      color: string;
      position: number;
      kind: string;
      rotDays: number;
    }>;
    fields: Array<{
      id: string;
      name: string;
      label: string;
      type: string;
      required: boolean;
      options: string[];
      question: string;
      position: number;
      inQuote: boolean;
      quotePosition: number;
      archived: boolean;
    }>;
    quotePreview: Array<{
      field: string;
      ask: string;
      input: string;
      choices: string[];
    }>;
  };
  templates: Array<{
    id: string;
    name: string;
    description: string;
    stages: Array<{
      name: string;
      kind: string;
      color: string;
      rotDays: number;
    }>;
    fields: Array<{
      name: string;
      label: string;
      type: string;
      options: string[];
      question: string;
      quote: boolean;
    }>;
  }>;
};

Use another template ​

POST /jobs/pipeline/template · scope settings:write

Replaces the stages and fields with a template's. Jobs keep their place by stage kind and order; fields the new template lacks are archived.

Body fieldRequiredDescription
templateyesservice-quote, projects, support, sales-demo, bookings, custom-orders or basic.

Request

bash
curl -X POST "$HELPPUFF_URL/api/v1/jobs/pipeline/template" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "template": "projects"
}'
ts
type UseAnotherTemplateRequest = {
  /**
   * `service-quote`, `projects`, `support`, `sales-demo`, `bookings`,
   * `custom-orders` or `basic`.
   */
  template: string;
};

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/pipeline/template`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    template: 'projects'
  } satisfies UseAnotherTemplateRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as UseAnotherTemplateResponse;

Response 200

json
{
  "pipeline": {
    "siteId": "acme",
    "template": "projects",
    "itemSingular": "Job",
    "itemPlural": "Jobs",
    "chosenBy": "owner",
    "reason": "A plumbing business with free on-site quotes.",
    "assistantJobs": true,
    "quote": {
      "enabled": true,
      "label": "Get a quote",
      "askContact": true
    },
    "editedAt": null,
    "stages": [
      {
        "id": "stg_3f2a1b0c9d8e7f6a",
        "name": "New request",
        "color": "#3b82f6",
        "position": 0,
        "kind": "open",
        "rotDays": 1
      }
    ],
    "fields": [
      {
        "id": "fld_1a2b3c4d5e6f7a8b",
        "name": "service",
        "label": "Service",
        "type": "select",
        "required": false,
        "options": [
          "Blocked drains",
          "Hot water"
        ],
        "question": "Which service do you need?",
        "position": 0,
        "inQuote": true,
        "quotePosition": 0,
        "archived": false
      }
    ],
    "quotePreview": [
      {
        "field": "service",
        "ask": "Which service do you need?",
        "input": "choice",
        "choices": [
          "Blocked drains",
          "Hot water"
        ]
      }
    ]
  },
  "templates": [
    {
      "id": "service-quote",
      "name": "Service quote",
      "description": "Trades and home or business services that quote before the work.",
      "stages": [
        {
          "name": "New request",
          "kind": "open",
          "color": "#3b82f6",
          "rotDays": 1
        }
      ],
      "fields": [
        {
          "name": "service",
          "label": "Service",
          "type": "select",
          "options": [],
          "question": "Which service do you need?",
          "quote": true
        }
      ]
    }
  ]
}
ts
type UseAnotherTemplateResponse = {
  pipeline: {
    siteId: string;
    template: string;
    itemSingular: string;
    itemPlural: string;
    chosenBy: string;
    reason: string | null;
    assistantJobs: boolean;
    quote: {
      enabled: boolean;
      label: string;
      askContact: boolean;
    };
    editedAt: number | null;
    stages: Array<{
      id: string;
      name: string;
      color: string;
      position: number;
      kind: string;
      rotDays: number;
    }>;
    fields: Array<{
      id: string;
      name: string;
      label: string;
      type: string;
      required: boolean;
      options: string[];
      question: string;
      position: number;
      inQuote: boolean;
      quotePosition: number;
      archived: boolean;
    }>;
    quotePreview: Array<{
      field: string;
      ask: string;
      input: string;
      choices: string[];
    }>;
  };
  templates: Array<{
    id: string;
    name: string;
    description: string;
    stages: Array<{
      name: string;
      kind: string;
      color: string;
      rotDays: number;
    }>;
    fields: Array<{
      name: string;
      label: string;
      type: string;
      options: string[];
      question: string;
      quote: boolean;
    }>;
  }>;
};

Let the AI set the pipeline up ​

POST /jobs/setup · scope settings:write

Reads the website (what the assistant learned, else the home page), chooses a template and customises it (the site's services as options, a stage for its process). Not sure: the basic template. With force (the default) it replaces the current pipeline. helppuff deploy calls it with force: false, which sets Jobs up only if it never was: status is kept (already set up), waiting (the website is still being learned; it is set up when learning finishes) or done. Uses Workers AI (a few hundred neurons).

Body fieldRequiredDescription
forcenoDefault true: choose again. False: only the first time, never over a pipeline the owner or the AI set up.

Request

bash
curl -X POST "$HELPPUFF_URL/api/v1/jobs/setup" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "force": true
}'
ts
type LetAISetPipelineUpRequest = {
  /**
   * Default true: choose again. False: only the first time, never over a
   * pipeline the owner or the AI set up.
   */
  force?: boolean;
};

const response = await fetch(`${process.env.HELPPUFF_URL}/api/v1/jobs/setup`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HELPPUFF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    force: true
  } satisfies LetAISetPipelineUpRequest),
});
if (!response.ok) throw new Error(((await response.json()) as ApiError).error.message);
const data = (await response.json()) as LetAISetPipelineUpResponse;

Response 200

json
{
  "status": "done",
  "template": "service-quote",
  "chosenBy": "ai",
  "reason": "A plumbing business with free on-site quotes.",
  "applied": true,
  "pipeline": {
    "siteId": "acme",
    "template": "service-quote",
    "itemSingular": "Job",
    "itemPlural": "Jobs",
    "chosenBy": "ai",
    "reason": "A plumbing business with free on-site quotes.",
    "assistantJobs": true,
    "quote": {
      "enabled": true,
      "label": "Get a quote",
      "askContact": true
    },
    "editedAt": null,
    "stages": [
      {
        "id": "stg_3f2a1b0c9d8e7f6a",
        "name": "New request",
        "color": "#3b82f6",
        "position": 0,
        "kind": "open",
        "rotDays": 1
      }
    ],
    "fields": [
      {
        "id": "fld_1a2b3c4d5e6f7a8b",
        "name": "service",
        "label": "Service",
        "type": "select",
        "required": false,
        "options": [
          "Blocked drains",
          "Hot water"
        ],
        "question": "Which service do you need?",
        "position": 0,
        "inQuote": true,
        "quotePosition": 0,
        "archived": false
      }
    ],
    "quotePreview": [
      {
        "field": "service",
        "ask": "Which service do you need?",
        "input": "choice",
        "choices": [
          "Blocked drains",
          "Hot water"
        ]
      }
    ]
  }
}
ts
type LetAISetPipelineUpResponse = {
  status: string;
  template: string;
  chosenBy: string;
  reason: string | null;
  applied: boolean;
  pipeline: {
    siteId: string;
    template: string;
    itemSingular: string;
    itemPlural: string;
    chosenBy: string;
    reason: string | null;
    assistantJobs: boolean;
    quote: {
      enabled: boolean;
      label: string;
      askContact: boolean;
    };
    editedAt: number | null;
    stages: Array<{
      id: string;
      name: string;
      color: string;
      position: number;
      kind: string;
      rotDays: number;
    }>;
    fields: Array<{
      id: string;
      name: string;
      label: string;
      type: string;
      required: boolean;
      options: string[];
      question: string;
      position: number;
      inQuote: boolean;
      quotePosition: number;
      archived: boolean;
    }>;
    quotePreview: Array<{
      field: string;
      ask: string;
      input: string;
      choices: string[];
    }>;
  };
};

MIT licensed. Runs in your own Cloudflare account.