---
title: "Create Spray Record"
method: POST
path: "/paddocks/{paddock_id}/spray-records"
tags: ["Spray records"]
---

# Create Spray Record

`POST /paddocks/{paddock_id}/spray-records`

## Request

- Base URL: `https://api.purplegrain.com/api/v1`
- URL: `https://api.purplegrain.com/api/v1/paddocks/{paddock_id}/spray-records`
- Auth: HTTP bearer

Create a task with a spray record on the existing paddock identified by paddock_id.
Returns the created task, including its spray child and product rows. Requires permission
and connection write access for the paddock's owner, which may be a person or business.

Accepts date, description, area_ha, up to ten products, application method as method,
equipment settings and weather_readings. Product fields include batch, expiry, rate,
grazing_whp_days, harvest_whp_days and export intervals. Never invent a batch, expiry,
rate or withholding period. Supply only values the farmer or label states. Missing
area_ha defaults to the paddock's area; date defaults to today. A past or today's date
creates a completed record at midday in the application time zone and requires at least
one product row. A future date creates a pending task with no completed_at.

The response derives grazing and harvest dates from the completion date and the longest
positive withholding period among named products; absent qualifying values produce null
dates. It stores supplied spray details and does not verify chemical suitability or label
compliance. Each successful call creates a new task; check for a same-date, same-product
duplicate before submitting or retrying an uncertain result. Confirm the proposed write
with the farmer, then call with confirm: true. An insufficient_access error means the
connection lacks the required access; show its detail instead of retrying the write.
**Photos.** A file on disk is staged with `createImageUpload`, whose `curl_example` PUTs the bytes, and attached with `image_upload_ids`. Never base64-encode a local photo into a tool call. A public http(s) URL: pass `image_urls`. Only a tiny generated image (under about 100 KB decoded) uses `image_base64s`. One upload can be attached to several records until it expires. On update, `image_mode` defaults to `append` (add to the existing photos); `replace` swaps them all out; `remove_image_ids` deletes specific ones. If any image fails, the whole create or update is rolled back. The attaching call still needs `confirm: true`. `createImageUpload` and its PUT do not.

## Path parameters

- `paddock_id` string, required

## Headers

- `Idempotency-Key` string

## Request body

- SprayRecordCreateModelAttributes: Flat request body (application/json) for POST /paddocks/{paddock_id}/spray-records: the fields directly under `data`. Equivalent nested shape: `SprayRecordCreateModel`.
  - `data` SprayRecordFields
    - `image_upload_ids` string[]: Staged uploads from `createImageUpload`, in gallery order. A file on the agent's disk is staged with `createImageUpload`, whose `curl_example` PUTs the bytes, and attached with these ids. Never base64-encode a local photo into a tool call. One upload can be attached to several records (up to 20) until it expires; they share the same photo.
    - `image_urls` string[]: Public http(s) URLs for the server to fetch, in gallery order. Use this when the image is already at a public URL.
    - `image_base64s` string[]: Base64 image data, optionally a `data:image/...;base64,` URI, in gallery order. Only for a tiny generated image under about 100 KB decoded. A local photo uses `createImageUpload` instead.
    - `image_mode` 'append' | 'replace': Updates only; ignored on create. `append` (the default) adds these photos to the ones already on the task. `replace` removes every existing photo and stores only the ones in this request.
    - `remove_image_ids` string[]: Updates only; ignored on create. Photos to remove from the gallery. Each id comes from the task's `image_ids` (or `images[].id`). Used with the default `append`, or on its own.
    - `date` string, date: The day the spray was done, `YYYY-MM-DD` (create only; default today). Back-date with a past date. The server stores midday in the application time zone, so the derived withholding dates do not slip a day. Do not send an offset datetime here — that belongs on a task's `completed_at`. A future date creates a pending task with no completed_at. Product expiry dates are supplied separately as products[].expiry.
    - `area_ha` union: Hectares sprayed ("1,240.5" is 1240.5). May be less than the paddock. Defaults to the paddock's area when omitted.
      - number
      - string
    - `products` SprayProductWrite[]: One entry per product in the tank. On update this REPLACES the set (position is the array index). A completed record with none is refused (422, "A spray needs at least one product").
      - `name` string, nullable: Product trade name, for example "Lontrel". Blank is valid, but a blank name does not contribute to the derived withholding dates.
      - `active` string, nullable: Label active constituent, for example "Clopyralid 300 g/L".
      - `batch` string, nullable: Batch number from the pack. Never invent one; leave it unset until the farmer confirms it.
      - `rate` string, nullable: Value, unit and basis, as entered, for example "300 mL / ha". Ask if the unit or the basis is missing.
      - `grazing_whp_days` union: Grazing withholding period from the label, in whole days. "7 d" is read as 7. Never invent it.
        - integer
        - string
      - `harvest_whp_days` union: Harvest withholding period from the label, in whole days. "28 d" is read as 28. If the label gives one withholding period for both, send it in both. Never invent it.
        - integer
        - string
      - `role` 'herbicide' | 'fungicide' | 'insecticide' | 'adjuvant', nullable: What the product is in the tank.
      - `expiry` string, date, nullable: Expiry date on the pack, `YYYY-MM-DD`. Never invent one; unset prints `not recorded`.
      - `rate_value` union: The number in the rate. Parsed from `rate` when omitted.
        - number
        - string
      - `rate_unit` 'g' | 'kg' | 't' | 'mL' | 'L' | 'seeds' | '%', nullable: Unit of `rate_value`. A rate with a number and no unit is refused (422).
      - `rate_basis` 'ha' | 'head' | '100_kg_seed' | '100_l' | 'total', nullable: What the rate is per.
      - `esi_days` union: Export slaughter interval from the label, in whole days. Never invent it.
        - integer
        - string
      - `egi_days` union: Export grazing interval from the label, in whole days. Never invent it.
        - integer
        - string
      - `eafi_days` union: Export animal feed interval from the label, in whole days. Never invent it.
        - integer
        - string
      - `do_not_graze` boolean: The label says DO NOT graze. Recorded on the spray and shown on the record; it gives the paddock no status, since it has no date.
      - `product_id` string, nullable: Optional link to the catalogue; the values here are copied at save.
    - `description` string: Create only: the record's title. Defaults to "Sprayed {paddock}".
    - `method` 'boom' | 'boomless' | 'aerial' | 'drone' | 'spot' | 'weed_wiper' | 'knapsack' | 'airblast', nullable: How it was applied.
    - `water_rate` union: Water rate in litres per hectare: 80 or "80 L/ha". Anything that is not litres per hectare is read as none, not guessed.
      - number
      - string
    - `nozzle` string, nullable: Brand, model and size, as entered.
    - `pressure_kpa` integer, nullable: Boom pressure, kPa.
    - `boom_height_m` union: Boom height, metres.
      - number
      - string
    - `speed_kmh` union: Travel speed, km/h.
      - number
      - string
    - `droplet_category` 'extremely_fine' | 'very_fine' | 'fine' | 'medium' | 'coarse' | 'very_coarse' | 'extremely_coarse' | 'ultra_coarse', nullable: Droplet size category.
    - `no_spray_zone_m` integer, nullable: Downwind no-spray zone kept, metres.
    - `re_entry_note` string, nullable: Re-entry period, worker safety, informational.
    - `neighbour_drift` boolean, nullable: Drift from a neighbour was a factor.
    - `weather_readings` SprayWeatherReadingWrite[]: The weather readings, as a list (Queensland wants readings before, during and after). On update this REPLACES the list.
      - `read_at` string, date-time, nullable: When it was taken, an ISO 8601 time.
      - `wind_kmh` union: Wind speed, km/h.
        - number
        - string
      - `gust_kmh` union: Gust, km/h.
        - number
        - string
      - `wind_dir` string, nullable: Compass point, e.g. `SW`.
      - `temp_c` number, nullable: Temperature, degrees C.
      - `rh_pct` integer, nullable: Relative humidity, per cent.
      - `delta_t` number, nullable: Delta T.
      - `cloud_pct` integer, nullable: Cloud cover, per cent.
      - `rain_mm` union: Rain, mm.
        - number
        - string
      - `inversion_observed` boolean, nullable: An inversion was observed.
      - `source` string, nullable: On-site, or a named station with its distance.

## Response `201`

Created. The task, with its `spray`.

- TasksShowResponseAttributes
  - `data` TaskModelAttributes: Task in attributes-adapter shape (Accept application/json); fields at top level.
    - `id` string
    - `type` string
    - `status` 'pending' | 'cancelled' | 'completed': `pending`, `cancelled` or `completed`. A treatment after the fact is `completed` with `created_as: record`. A planned treatment is `pending` with `created_as: task`.
    - `completed_at` string, date-time, nullable: When the task was completed. The server stamps it as status becomes completed and clears it when the task leaves completed, unless the client sent an explicit value — that wins, including a back-dated time. Stored and returned in UTC: `2025-12-23T09:00:00+11:00` and `2025-12-22T22:00:00Z` are the same moment. AEDT is +11:00 (first Sunday of October to first Sunday of April); AEST is +10:00. `createTreatmentRecord` stores midday in the application time zone from a `YYYY-MM-DD` date.
    - `created_at` string, date-time
    - `updated_at` string, date-time
    - `description` string, nullable
    - `tags` string[]: Hashtag-style labels (they work like Twitter hashtags) — free-form lowercase tokens stored without a leading hash, e.g. ["slashing", "north-paddock"]. Reusing a tag groups records together, and the /tasks/search and /services/search term parameter matches tags case-insensitively, so a tag aggregates everything labelled with it. A drench may carry `drench`, one lowercase-hyphenated tag per product, `whp-until-YYYY-MM-DD`, `esi-until-YYYY-MM-DD`, `needs-review` when something is unconfirmed, and `import-<source-id>` to group one imported sheet. Withholding dates come from the product days, not from tags. Always an array — the API never returns null.
    - `image_url` string, nullable
    - `image_thumb_url` string, nullable
    - `image_ids` string[], nullable: Attachment ids for the photo gallery, aligned index-for-index with image_urls. Use one as the image_id in DELETE /tasks/{id}/images/{image_id}.
    - `image_urls` string[], nullable: The photo gallery, oldest first — the first entry is the cover (also exposed as the singular image_url).
    - `image_thumb_urls` string[], nullable: Thumbnail variants of image_urls, same order.
    - `images` TaskImage[], nullable: The gallery, oldest first. Position 0 is the cover (`image_url`). Same order as `image_urls` and `image_ids`.
      - `id` string, required: Gallery attachment id. The same value is in `image_ids`, and it is what `remove_image_ids` and `DELETE /tasks/{id}/images/{image_id}` take.
      - `url` string, nullable
      - `thumb_url` string, nullable
      - `content_type` string, nullable
      - `byte_size` integer, nullable
      - `width` integer, nullable
      - `height` integer, nullable
      - `position` integer, required: Gallery order, starting at 0. The photo at position 0 is the cover (`image_url`).
      - `source` 'upload' | 'url' | 'base64', nullable: How the photo was attached. Blank on photos stored before this field existed, and on a multipart upload.
    - `url` string, nullable
    - `service_id` string, nullable
    - `share_scope` 'private' | 'broadcast', nullable: Who can see the task — `private` (the owner's board only) or `broadcast` (shared with local contractors). Write it through `broadcast`. An API or MCP client that sends `broadcast` or `share_scope` is refused with 403 and nothing is written.
    - `broadcast` boolean: Whether the task is shared with local contractors (`share_scope` is `broadcast`). Writable on create and update — send `true` to share, `false` to un-share; sharing is free.
    - `broadcast_payment_status` string, nullable: Legacy, read-only — sharing is free and no current operation writes this field.
    - `broadcast_paid_at` string, date-time, nullable: Legacy, read-only — see broadcast_payment_status.
    - `fulfillment_status` string, nullable
    - `position` integer, nullable: Order within the sibling set (or within the top level). Only meaningful relative to siblings; reordering rewrites it and must never touch status or completed_at.
    - `sequenced` boolean: On a parent, its children run in order. The UI numbers them and marks later steps "after step N"; completing out of sequence stays allowed, so this drives presentation only.
    - `progress` object, nullable: Derived roll-up over the children — never stored. Null when the task has no children, so a plain task is distinguishable from a campaign with nothing done yet.
      - `done` integer
      - `total` integer
    - `operation_type` string, nullable: What kind of work this is — one of `ai`, `spray`, `treatment`, `husbandry`, `fertiliser`, `drench`, `spread`, `sow`, `fieldwork`, `harvest`, `move_stock`, `shear`, `crutch`, `feed`, `fence`, `slash`, `observation`. `ai` is a PurpleGrain AI record: the farmer's own words (and photos) queued for the formatter, with no typed child and possibly no asset (see `ai_format_done_at`). Create one with `POST /tasks` and `operation_type: ai` from the PurpleGrain app or an API key (an MCP client is refused with 403: an assistant knows the type and writes the typed record itself); when the formatter finishes it replaces `ai` with the real type and writes the typed child, or clears the type and leaves a plain task if it gives up. `treatment` is an animal treatment on one mob. Save it with `createTreatmentRecord` and the appropriate `category` (`drench` for a drench, `vaccine` for a vaccination), which stores the products and derives the withholding dates. `drench` remains a deprecated operation type. `spray` is a treatment of one paddock (herbicide, pesticide, fungicide): save it with `createSprayRecord`, which stores the products and derives the graze and harvest dates. Deliberately a free string with no enum: the vocabulary is still moving, and a schema stricter than the model rejects valid traffic the day request validation is enforced — before authentication runs.
    - `capture_source` string, nullable: How the entry was captured — `typed`, `voice`, `agent` or `import`. `form` is accepted and stored as `typed`. A PurpleGrain AI record sends `voice` or `typed`. Agents send `agent`. A photographed or uploaded sheet is `import`. `createTreatmentRecord` sets `agent` itself. Free string with no enum, for the same reason as operation_type.
    - `operator` string, nullable: Who treated or did the work, as a display name or initials (for example "AH"). Free text: a name on the diary entry, not a link to a user. `createTreatmentRecord` sets this from the signed-in user and does not take it in the body; send it here when someone else treated.
    - `record_summary` string, nullable: The composed line a completed row shows on the timeline, for example "Drenched Rams (11 hd): Startect 20 mL/hd. WHP until 6 Jan 2026, ESI until 20 Jan 2026". Distinct from `description`, which stays the authored task text. `createTreatmentRecord` leaves this empty; the withholding dates are `treatment.safe_for_slaughter_on` and `treatment.export_eligible_on`.
    - `record_notes` string, nullable: The farmer's own words from the capture, unedited, rendered under the summary when the two differ. Product, batch, dose, WHP and ESI belong on `treatment.products` — that is what the withholding dates are calculated from. Expiry and route are fields of the treatment (`treatment.route`, each product's `expiry`); leave them unset rather than inventing either. Never invent a batch, expiry, WHP or ESI.
    - `drench` DrenchRead: The drench of a task or record. `safe_for_slaughter_on` and `export_eligible_on` are DERIVED (never stored) from the completion date plus the longest WHP / ESI among named products, and null while the task is open or until a named product carries a withholding period.
      - `id` string: The DrenchRecord id — the same before and after the task is completed.
      - `head` integer
      - `products` DrenchProductRead[]
        - `id` string
        - `name` string, nullable: What went in the gun. Blank is valid — a drench recorded before anyone said what it was.
        - `active` string, nullable: Label active constituent, e.g. `Abamectin 1 g/L`.
        - `batch` string, nullable: Batch number from the pack. Null when it was not recorded. Never invented.
        - `expiry` string, date, nullable: Expiry date on the pack, `YYYY-MM-DD`. Null when it was not recorded. Never invented.
        - `dose` string, nullable: As entered, e.g. `5 mL / 20 kg`.
        - `dose_value` number, nullable: The number in the dose, e.g. 5. Only what the caller sent; never parsed from `dose`.
        - `dose_unit` 'mL' | 'L' | 'g' | 'kg', nullable: Unit of `dose_value`.
        - `dose_basis` 'per_head' | 'per_kg' | 'per_mob', nullable: What `dose_value` is per.
        - `whp_days` integer: Withholding period, days.
        - `esi_days` integer: Export slaughter interval, days.
        - `position` integer: Order the products were entered in.
      - `safe_for_slaughter_on` string, date, nullable
      - `export_eligible_on` string, date, nullable
    - `spray` SprayRead: The spray of a task or record. `safe_to_graze_on` and `safe_to_harvest_on` are DERIVED (never stored) from the completion date plus the longest grazing / harvest WHP among named products, and null while the task is open or until a named product carries a value above zero for that period.
      - `id` string: The SprayRecord id — the same before and after the task is completed.
      - `area_ha` number, nullable: Hectares sprayed. Defaults to the paddock's area when the child is created without one and again when the task closes; null when the paddock has no area either.
      - `products` SprayProductRead[]
        - `id` string
        - `name` string, nullable: What went in the tank. Blank is valid — a spray recorded before anyone said what it was.
        - `active` string, nullable: Label active constituent, e.g. `Clopyralid 300 g/L`.
        - `batch` string, nullable: Batch number from the pack. Null when it was not recorded. Never invented.
        - `rate` string, nullable: As entered, e.g. `300 mL / ha`.
        - `grazing_whp_days` integer: Grazing withholding period, days.
        - `harvest_whp_days` integer: Harvest withholding period, days.
        - `position` integer: Order the products were entered in.
        - `role` 'herbicide' | 'fungicide' | 'insecticide' | 'adjuvant', nullable: What the product is in the tank.
        - `expiry` string, date, nullable: Expiry date on the pack, `YYYY-MM-DD`. Never invent one; unset prints `not recorded`.
        - `rate_value` number, nullable
        - `rate_unit` 'g' | 'kg' | 't' | 'mL' | 'L' | 'seeds' | '%', nullable: Unit of `rate_value`. A rate with a number and no unit is refused (422).
        - `rate_basis` 'ha' | 'head' | '100_kg_seed' | '100_l' | 'total', nullable: What the rate is per.
        - `esi_days` integer
        - `egi_days` integer
        - `eafi_days` integer
        - `do_not_graze` boolean: The label says DO NOT graze. Recorded on the spray and shown on the record; it gives the paddock no status, since it has no date.
        - `product_id` string, nullable: Optional link to the catalogue; the values here are copied at save.
      - `safe_to_graze_on` string, date, nullable
      - `safe_to_harvest_on` string, date, nullable
      - `method` 'boom' | 'boomless' | 'aerial' | 'drone' | 'spot' | 'weed_wiper' | 'knapsack' | 'airblast', nullable: How it was applied.
      - `water_rate_l_ha` number, nullable
      - `nozzle` string, nullable: Brand, model and size, as entered.
      - `pressure_kpa` integer, nullable: Boom pressure, kPa.
      - `boom_height_m` number, nullable
      - `speed_kmh` number, nullable
      - `droplet_category` 'extremely_fine' | 'very_fine' | 'fine' | 'medium' | 'coarse' | 'very_coarse' | 'extremely_coarse' | 'ultra_coarse', nullable: Droplet size category.
      - `no_spray_zone_m` integer, nullable: Downwind no-spray zone kept, metres.
      - `re_entry_note` string, nullable: Re-entry period, worker safety, informational.
      - `neighbour_drift` boolean, nullable: Drift from a neighbour was a factor.
      - `do_not_graze` boolean: A named product's label says DO NOT graze. Recorded on the spray; it gives the paddock no status, since it has no date.
      - `weather_readings` SprayWeatherReadingRead[]
        - `id` string
        - `read_at` string, date-time, nullable: When it was taken, an ISO 8601 time.
        - `wind_kmh` union: Wind speed, km/h.
          - number
          - string
        - `gust_kmh` union: Gust, km/h.
          - number
          - string
        - `wind_dir` string, nullable: Compass point, e.g. `SW`.
        - `temp_c` number, nullable: Temperature, degrees C.
        - `rh_pct` integer, nullable: Relative humidity, per cent.
        - `delta_t` number, nullable: Delta T.
        - `cloud_pct` integer, nullable: Cloud cover, per cent.
        - `rain_mm` union: Rain, mm.
          - number
          - string
        - `inversion_observed` boolean, nullable: An inversion was observed.
        - `source` string, nullable: On-site, or a named station with its distance.
        - `position` integer
    - `treatment` TreatmentRead: The treatment of a task or record. Null on a task that has none.
      - `id` string: The TreatmentRecord id: the same before and after the task is completed.
      - `safe_for_slaughter_on` string, date, nullable: DERIVED, never stored: the completion date plus the longest meat WHP among named products. Null while the task is open or until a named product carries one.
      - `export_eligible_on` string, date, nullable: DERIVED: the completion date plus the longest ESI among named products.
      - `category` 'drench' | 'fluke' | 'lice' | 'flystrike' | 'other_external_parasite' | 'vaccine' | 'vitamin_mineral' | 'antibiotic' | 'pain_relief' | 'feed_water_additive' | 'foot_treatment' | 'reproductive' | 'hgp' | 'other', nullable: What kind of treatment. Required on create. A `drench` is the everyday word for category `drench`.
      - `route` 'oral' | 'injection' | 'backline' | 'jetting' | 'dip' | 'capsule' | 'topical' | 'in_feed_water', nullable: How it was given. Leave it unset if the farmer did not say; it prints `not recorded`.
      - `head` integer, nullable: Head treated; separators are stripped ("1,240" is 1240). Defaults to the mob's head count.
      - `equipment_cleaned` boolean, nullable: ISC checklist item; unset is not recorded.
      - `adverse_reaction` string, nullable: Any adverse reaction or broken needle. LPA asks for both to be recorded and passed on.
      - `hgp_earmarked` boolean, nullable: HGP implant: triangle ear mark applied (cattle).
      - `vet_authorisation` string, nullable: Off-label use: who authorised it.
      - `products` TreatmentProductRead[]
        - `id` string
        - `name` string, nullable: Product trade name. Blank is valid, but a blank name contributes nothing to the derived withholding dates.
        - `active` string, nullable: Label active constituent, e.g. `Moxidectin 1 g/L`.
        - `batch` string, nullable: Batch number from the pack. Never invent one; leave it unset until the farmer confirms it.
        - `expiry` string, date, nullable: Expiry date on the pack, `YYYY-MM-DD`. Never invent one; unset prints `not recorded`.
        - `dose` string, nullable: The dose as entered, e.g. `5 mL / 20 kg`. Kept verbatim.
        - `dose_value` number, nullable: The number in the dose, e.g. 5. Only what the caller sends; it is never parsed from `dose`.
        - `dose_unit` 'mL' | 'L' | 'g' | 'kg', nullable: Unit of `dose_value`.
        - `dose_basis` 'per_head' | 'per_kg' | 'per_mob', nullable: What `dose_value` is per.
        - `drench_group` string, nullable: Chemical group, e.g. `BZ`, `LV`, `ML`, `AD`, `SI`, `OP`, `TZ`, `SA`, `IQ` or a combination, for resistance tracking.
        - `whp_days` integer, nullable: Meat withholding period from the label, in whole days. "14 d" is read as 14. Never invent it.
        - `esi_days` integer, nullable: Export slaughter interval from the label, in whole days. If the label gives none, send the WHP. Never invent it.
        - `wool_interval_days` integer, nullable: Wool harvest interval, days. 0 is none stated.
        - `milk_whp_days` integer, nullable: Milk withholding period, days. 0 is none stated.
        - `product_id` string, nullable: Optional link to the catalogue (`listProducts`). The values here are copied at save, so a later catalogue edit never changes a past record.
        - `position` integer: Order the products were entered in.
    - `husbandry` HusbandryRead: The husbandry of a task or record. Null on a task that has none.
      - `id` string: The HusbandryRecord id.
      - `marking_percent` integer, nullable: Lambs marked as a share of ewes joined, worked out when read. Null until both are said.
      - `safe_for_slaughter_on` string, date, nullable: DERIVED from the products' meat WHP; null when no named product carries one.
      - `export_eligible_on` string, date, nullable: DERIVED from the products' ESI.
      - `head` integer, nullable: Head the work was done to; separators are stripped. Defaults to the mob's head count.
      - `procedures` string[]: What was done. One or more.
      - `method` string, nullable: Free text, e.g. rings or knife.
      - `outcomes` object: One block per procedure, e.g. `{ "mark": { "lambs_marked": 412, "ewes_joined": 350 } }`. Figures are numbers, zero or more, except `shearer`, which is text.
      - `products` HusbandryProductRead[]
        - `id` string
        - `name` string, nullable: Product trade name. Blank is valid, but a blank name contributes nothing to the derived withholding dates.
        - `active` string, nullable: Label active constituent, e.g. `Moxidectin 1 g/L`.
        - `batch` string, nullable: Batch number from the pack. Never invent one; leave it unset until the farmer confirms it.
        - `expiry` string, date, nullable: Expiry date on the pack, `YYYY-MM-DD`. Never invent one; unset prints `not recorded`.
        - `dose` string, nullable: The dose as entered, e.g. `5 mL / 20 kg`. Kept verbatim.
        - `dose_value` number, nullable: The number in the dose, e.g. 5. Only what the caller sends; it is never parsed from `dose`.
        - `dose_unit` 'mL' | 'L' | 'g' | 'kg', nullable: Unit of `dose_value`.
        - `dose_basis` 'per_head' | 'per_kg' | 'per_mob', nullable: What `dose_value` is per.
        - `drench_group` string, nullable: Chemical group, e.g. `BZ`, `LV`, `ML`, `AD`, `SI`, `OP`, `TZ`, `SA`, `IQ` or a combination, for resistance tracking.
        - `whp_days` integer, nullable: Meat withholding period from the label, in whole days. "14 d" is read as 14. Never invent it.
        - `esi_days` integer, nullable: Export slaughter interval from the label, in whole days. If the label gives none, send the WHP. Never invent it.
        - `wool_interval_days` integer, nullable: Wool harvest interval, days. 0 is none stated.
        - `milk_whp_days` integer, nullable: Milk withholding period, days. 0 is none stated.
        - `product_id` string, nullable: Optional link to the catalogue (`listProducts`). The values here are copied at save, so a later catalogue edit never changes a past record.
        - `position` integer: Order the products were entered in.
    - `fertiliser` FertiliserRead: The fertiliser of a task or record. Null on a task that has none.
      - `id` string: The FertiliserRecord id.
      - `nutrients_applied` object: Kilograms per hectare of each nutrient across the lines that state an analysis and a rate per hectare in kg or t. Worked out when read.
      - `area_ha` number, nullable: Hectares covered; separators are stripped. Defaults to the paddock's area.
      - `method` 'broadcast' | 'banded' | 'drilled' | 'in_furrow' | 'fertigation' | 'foliar', nullable: How it was applied.
      - `incorporation` 'none' | 'mechanical' | 'rain' | 'irrigation', nullable: How it was incorporated.
      - `incorporated_at` string, date-time, nullable: When it was incorporated, an ISO 8601 time.
      - `spreader` string, nullable: The spreader used.
      - `bout_width_m` number, nullable: Spread width, metres.
      - `cost_per_ha` number, nullable: Cost per hectare, for gross margins.
      - `products` FertiliserProductRead[]
        - `id` string
        - `name` string, nullable: Product, e.g. `Urea`. Blank is valid.
        - `analysis` object: Nutrient percentages, e.g. `{ "N": 46 }`, copied from the catalogue so a later edit never changes a past record.
        - `rate` string, nullable: The rate as entered, e.g. `100 kg/ha`. Kept verbatim.
        - `rate_value` number, nullable: The number in the rate. Parsed from `rate` when omitted.
        - `rate_unit` 'g' | 'kg' | 't' | 'mL' | 'L', nullable: Unit of `rate_value`. Required when `rate_value` is set.
        - `rate_basis` 'ha' | 'total', nullable: What the rate is per.
        - `batch` string, nullable: Batch number. Never invent one.
        - `supplier` string, nullable: Who supplied it.
        - `advisory_note` string, nullable: Grey advice, never a status: e.g. wait three to four weeks before grazing.
        - `product_id` string, nullable: Optional link to the catalogue.
        - `position` integer: Order the products were entered in.
        - `total_kg` number, nullable: The whole quantity put on, in kilograms: the rate per hectare times the area covered. Null when either is missing.
    - `sowing` SowingRead: The sowing of a task or record. Null on a task that has none.
      - `id` string: The SowingRecord id.
      - `crop_sown` string, nullable: What the paddock carries once this is completed: the seed line's variety, else `crop`.
      - `safe_to_graze_on` string, date, nullable: DERIVED: the completion date plus the longest grazing WHP among named seed-treatment lines. Null while the task is open or until one carries it.
      - `area_ha` number, nullable: Hectares sown; separators are stripped. Defaults to the paddock's area.
      - `crop` string, nullable: e.g. `Barley`. The variety goes on the seed line.
      - `row_spacing_mm` integer, nullable: Row spacing, millimetres.
      - `depth_mm` integer, nullable: Sowing depth, millimetres.
      - `seeder` string, nullable: The seeder used.
      - `opener` string, nullable: Opener type.
      - `speed_kmh` number, nullable: Sowing speed, km/h.
      - `stubble` string, nullable: Stubble at sowing.
      - `soil_moisture` string, nullable: Soil moisture at sowing.
      - `target_plants_m2` number, nullable: Target plant density, plants per square metre.
      - `products` SowingProductRead[]
        - `id` string
        - `role` 'seed' | 'fertiliser' | 'seed_treatment' | 'inoculant', nullable: What this line is.
        - `name` string, nullable: Product or seed, e.g. `Spartacus CL barley`. Blank is valid.
        - `variety` string, nullable: Seed lines only. Completing the sowing sets the paddock's current crop to this.
        - `seed_source` 'retained' | 'purchased' | 'shared', nullable: Seed lines only.
        - `seed_lot` string, nullable: Seed lot or certificate number.
        - `germination_pct` number, nullable: Germination, 0 to 100.
        - `tgw_g` number, nullable: Thousand-grain weight, grams.
        - `rate` string, nullable: The rate as entered, e.g. `80 kg/ha`. Kept verbatim.
        - `rate_value` number, nullable: The number in the rate. Parsed from `rate` when omitted.
        - `rate_unit` 'g' | 'kg' | 't' | 'mL' | 'L' | 'seeds' | '%', nullable: Unit of `rate_value`. Required when `rate_value` is set.
        - `rate_basis` 'ha' | 'head' | '100_kg_seed' | '100_l' | 'total', nullable: What the rate is per.
        - `placement` 'with_seed' | 'banded', nullable: Fertiliser lines only.
        - `active` string, nullable: Seed treatments: the active constituent.
        - `analysis` object: Fertiliser lines: nutrient percentages by weight, lower-case keys, e.g. `{ "n": 10, "p": 22 }`, copied from the catalogue.
        - `batch` string, nullable: Batch number. Never invent one.
        - `expiry` string, date, nullable: Expiry date, `YYYY-MM-DD`.
        - `grazing_whp_days` integer, nullable: Seed treatments only: the grazing withholding period from the label, in whole days. Never invent it.
        - `esi_days` integer, nullable: Seed treatments only: the export slaughter interval, in whole days.
        - `inoculant_group` string, nullable: Inoculants only: the group, e.g. `E` for lentils.
        - `product_id` string, nullable: Optional link to the catalogue.
        - `position` integer: Order the products were entered in.
    - `fieldwork` FieldworkRead: The field work of a task or record. Null on a task that has none.
      - `id` string: The FieldworkRecord id.
      - `kind` 'cultivate' | 'harvest' | 'hay_silage' | 'slash' | 'roll' | 'burn' | 'irrigate', nullable: What was done.
      - `area_ha` number, nullable: Hectares worked; separators are stripped. Defaults to the paddock's area.
      - `implement` string, nullable: The implement or machine, e.g. `Case 8250`.
      - `outcomes` object: One block per kind, e.g. `{ "harvest": { "tonnes": 399, "yield_t_ha": 4.2, "grade": "Malt 1" } }`.
      - `products` FieldworkProductRead[]
        - `id` string
        - `name` string, nullable: Optional: a hay preservative. Blank is valid.
        - `active` string, nullable: Active constituent.
        - `batch` string, nullable: Batch number. Never invent one.
        - `expiry` string, date, nullable: Expiry date, `YYYY-MM-DD`.
        - `rate` string, nullable: The rate as entered. Kept verbatim.
        - `rate_value` number, nullable: The number in the rate. Parsed from `rate` when omitted.
        - `rate_unit` 'g' | 'kg' | 't' | 'mL' | 'L' | 'seeds' | '%', nullable: Unit of `rate_value`.
        - `rate_basis` 'ha' | 'head' | '100_kg_seed' | '100_l' | 'total', nullable: What the rate is per.
        - `product_id` string, nullable: Optional link to the catalogue.
        - `position` integer: Order the products were entered in.
    - `warnings` object[], nullable: Field work only: what the paddock was held back from on the day of the work (a harvest before a harvest withholding period ends, hay or silage before a grazing one does). Null for any other task, an empty list when there is nothing to say. A warning never blocks the write.
      - `code` 'harvest_before_withholding_ends' | 'hay_before_grazing_withholding_ends'
      - `message` string
      - `ends_on` string, date
      - `products` string[]
    - `created_as` 'task' | 'record', nullable: `task` when the row was created open and completed later, `record` when it was captured already done. A treatment after the fact is `status: completed` and `created_as: record`. A planned treatment is `status: pending` and `created_as: task`, and is completed later. `createTreatmentRecord` sets both from the date: a future date is the open task. Set once at creation and ignored on update. `can_reopen` is true only when this is `task`.
    - `can_reopen` boolean: Read-only. Whether the Reopen affordance applies — true exactly when `created_as` is `task`. A record that was born done has no open task to reopen to.
    - `ai_format_done_at` string, date-time, nullable: Read-only. When PurpleGrain AI finished with this task. Null while it is queued (and for every task that never was an AI record). Set once: a task with this set is never formatted again, and editing it does not re-queue it.
    - `ai_written_at` string, date-time, nullable: Read-only. When PurpleGrain AI first wrote this task's typed details. Null until it has, and for every task it never formatted. Set once: a re-run rewrites the details but does not move it. The app shows "Details written by PurpleGrain AI 3 Sep" and offers Re-run from it.
    - `ai_queued` boolean: Read-only. Whether PurpleGrain AI holds this task right now: a record saved as `ai` that has no typed child yet, or any task sent (back) with `rerun_ai` and not finished. Read this, not `operation_type`, for the queued state: after a re-run the type and the typed details stay while the task waits.
    - `ai_format_meta` object: Read-only. PurpleGrain AI's audit handle for this task, `{}` until the formatter has run: `outcome` (`clean`, `ambiguous`, `unplaced`, `needs_input`, `not_a_record`, `skipped`, `retrying` or `gave_up`), `model`, `input_tokens`, `output_tokens`, `latency_ms`, `prompt_hash`, `questions` (what the farmer must supply), `candidates` (the mobs or paddocks the words could mean, `{kind, id, name}`, when the asset was left unassigned), and `unconfirmed` (the product fields, as `products.0.batch`, that the farmer's words and photos do not support: stored, never confirmed). The model's raw response is not served.
    - `from_paddock_id` string, nullable: Where a mob was before this record moved it, when it was anywhere. Null on a first placement.
    - `to_paddock_id` string, nullable: Where a mob went. Set by POST /mobs/{id}/move, and by completing a task about a mob whose text carries a `#Paddock` hashtag. A mob's current paddock is derived from this column on its most recent completed record — never from `operation_type`.
    - `asset` object, nullable: Inlined when include=asset — the paddock or mob this work is about, as a PaddockModelAttributes or MobModelAttributes
    - `producer` object: Inlined when include=producer
    - `provider` object: Inlined when include=provider
    - `place` object: Inlined when include=place
    - `service` object: Inlined when include=service
    - `category` object: Inlined when include=category
    - `farm` object, nullable: Inlined when include=farm
    - `parent` object, nullable: Inlined when include=parent
    - `children` object[]: Inlined when include=children — the sub-tasks in board order, each a TaskModelAttributes

## Other responses

- `401`: Unauthenticated
- `403`: Not allowed to write on this paddock. For an assistant the body names the business, the level and where to change it (`insufficient_access`).
- `404`: No such paddock.
- `422`: Invalid request.

---

[API](https://skmtc.dev/purplegrain/apis/purple-grain-api.md) · [All operations](https://skmtc.dev/purplegrain/apis/purple-grain-api/llms.txt) · [OpenAPI document](https://skmtc.dev/purplegrain/apis/purple-grain-api/revisions/06540345bbbc?raw)
