API Reference Jobs - knowtific/helppuff GitHub Wiki

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.

Parameter In Required Description
status query no open (default), won, lost or all.
stage query no One stage's id.
assigned query no me, none, or a team member's email.
source query no chat, quote, api, manual or callback.
contact query no One contact's jobs (their lead id).
conversation query no The jobs that came from one conversation.
q query no Search title, details, fields, the contact, or a number (1042).
site query no The site (a key always uses its own).

Request

curl "$HELPPUFF_URL/api/v1/jobs" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
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

{
  "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": "[email protected]",
        "phone": "0400 111 222"
      },
      "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
      "source": "api",
      "value": 450,
      "valueCents": 45000,
      "currency": "AUD",
      "dueAt": null,
      "assignedTo": "[email protected]",
      "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
    }
  ]
}
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 field Required Description
title no Up to 160 characters. Left out: made from the fields and the contact.
details no What is needed, up to 10,000 characters.
fields no Values by field name, e.g. { "service": "Blocked drains" }. A select field takes one of its options.
contact no { name, email, phone }: matched by email, then phone, or created.
contactId no An existing contact's id, instead of contact.
conversationId no The conversation it came from.
callbackId no A callback request to turn into a job (its contact, conversation and reason).
stageId no The stage to start in.
value no What it is worth, e.g. 450 (in currency).
currency no ISO 4217, e.g. AUD.
dueAt no ISO 8601 date, or milliseconds.
assignedTo no A team member's email.

Request

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": "[email protected]",
    "phone": "0400 111 222"
  },
  "value": 450,
  "currency": "AUD"
}'
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: '[email protected]',
      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

{
  "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": "[email protected]",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 450,
  "valueCents": 45000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "[email protected]",
  "assignedName": "Sam",
  "position": 0,
  "stale": false,
  "stageChangedAt": 1760000000000,
  "closedAt": null,
  "lostReason": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
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

Status Code When
400 bad_request An 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.

Parameter In Required Description
id path yes The record's id.

Request

curl "$HELPPUFF_URL/api/v1/jobs/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
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

{
  "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": "[email protected]",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 450,
  "valueCents": 45000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "[email protected]",
  "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": "[email protected]",
      "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
  }
}
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.

Parameter In Required Description
id path yes The record's id.
Body field Required Description
title no Up to 160 characters.
details no Replaces the details. To add to them, use POST /jobs/:id/updates.
fields no Values by field name; null clears one.
value no A number, or null.
currency no ISO 4217.
dueAt no ISO 8601 date, milliseconds, or null.
assignedTo no A team member's email, or null.

Request

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
}'
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

{
  "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": "[email protected]",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 520,
  "valueCents": 52000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "[email protected]",
  "assignedName": "Sam",
  "position": 0,
  "stale": false,
  "stageChangedAt": 1760000000000,
  "closedAt": null,
  "lostReason": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
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.

Parameter In Required Description
id path yes The record's id.
Body field Required Description
stageId no The stage to move it to.
before no The id of the job it now sits above, or null for the bottom.
lostReason no Why, when moving to a lost stage.

Request

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"
}'
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

{
  "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": "[email protected]",
    "phone": "0400 111 222"
  },
  "conversationId": "1b0f6a52-3c1e-4c55-9f0e-2d1c7a9e5b10",
  "source": "api",
  "value": 450,
  "valueCents": 45000,
  "currency": "AUD",
  "dueAt": null,
  "assignedTo": "[email protected]",
  "assignedName": "Sam",
  "position": 0,
  "stale": false,
  "stageChangedAt": 1760000000000,
  "closedAt": null,
  "lostReason": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
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.

Parameter In Required Description
id path yes The record's id.
Body field Required Description
text yes Up to 4000 characters.

Request

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."
}'
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

{
  "id": "job_7c2e9a41b5d03f68",
  "added": true
}
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.

Parameter In Required Description
id path yes The record's id.
Body field Required Description
text yes Up to 5000 characters.

Request

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."
}'
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

{
  "id": "note_mfx2k1a9",
  "jobId": "job_7c2e9a41b5d03f68",
  "author": "key:k7m3p9q2r4s8",
  "authorName": "Website backend",
  "text": "Gate code 4471.",
  "createdAt": 1760000000000,
  "updatedAt": 1760000000000
}
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.

Parameter In Required Description
id path yes The record's id.

Request

curl "$HELPPUFF_URL/api/v1/jobs/ID/files" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
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

{
  "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": "[email protected]",
      "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
    }
  ]
}
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.

Parameter In Required Description
id path yes The record's id.
name query yes The file name, with its extension.

Request

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
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

{
  "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"
}
type AttachFileToJobResponse = {
  id: string;
  name: string;
  type: string;
  size: number;
  url: string;
  createdAt: number;
  uploadedBy: string;
  uploadedByName: string | null;
};

Errors

Status Code When
400 bad_request Files on jobs are off, the file is empty, too large, or not a kind HelpPuff keeps.
404 not_found No 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.

Parameter In Required Description
id path yes The record's id.
fileId path yes The fileId.

Request

curl -X DELETE "$HELPPUFF_URL/api/v1/jobs/ID/files/FILEID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
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

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

Delete a job

DELETE /jobs/:id · scope jobs:write

With its history and notes. The contact stays.

Parameter In Required Description
id path yes The record's id.

Request

curl -X DELETE "$HELPPUFF_URL/api/v1/jobs/ID" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
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

{
  "id": "job_7c2e9a41b5d03f68",
  "deleted": true
}
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.

Parameter In Required Description
site query no The site (a key always uses its own).

Request

curl "$HELPPUFF_URL/api/v1/jobs/pipeline" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY"
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

{
  "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
        }
      ]
    }
  ]
}
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 field Required Description
stages no [{ id?, name, kind: "open"|"won"|"lost", color?, rotDays? }].
fields no [{ id?, label, type: "text"|"textarea"|"number"|"date"|"select"|"email"|"tel", required?, options?, question? }].
quote no Field names asked by the widget's quote questions, in order (up to 10).
quoteEnabled no Offer the quote questions in the widget ("Get a quote").
quoteLabel no The button's label.
quoteContact no Also ask for name, email and phone at the end.
assistantJobs no Let the assistant create jobs from the chat.
itemSingular no What a job is called, e.g. "Ticket".
itemPlural no Its plural.
moveTo no { <removed stage id>: <stage id> }.

Request

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"
}'
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

{
  "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
        }
      ]
    }
  ]
}
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 field Required Description
template yes service-quote, projects, support, sales-demo, bookings, custom-orders or basic.

Request

curl -X POST "$HELPPUFF_URL/api/v1/jobs/pipeline/template" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "template": "projects"
}'
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

{
  "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
        }
      ]
    }
  ]
}
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 field Required Description
force no Default true: choose again. False: only the first time, never over a pipeline the owner or the AI set up.

Request

curl -X POST "$HELPPUFF_URL/api/v1/jobs/setup" \
  -H "Authorization: Bearer $HELPPUFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "force": true
}'
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

{
  "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"
        ]
      }
    ]
  }
}
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[];
    }>;
  };
};
⚠️ **GitHub.com Fallback** ⚠️