Writing records
Five ways to change a record:
POST …/records/ | Create one. Returns 201 and a Location header |
PATCH …/records/{id}/ | Update named fields on one record |
PUT …/records/?matching_property=X | Upsert, matched on X. 201 on create, 200 on update |
DELETE …/records/{id}/ | Delete one |
PUT …/records/{id}/associations/… | Link two records |
For many records at once, see batch operations.
PATCH: omitted is not the same as null
Section titled “PATCH: omitted is not the same as null”This distinction catches people out:
{ "properties": { "ahoy_email": null } }An explicit null clears the field. A field you simply leave out is left
unchanged. So a partial update is safe — you never have to send the whole
record back to avoid wiping it — but you must be deliberate about null.
Replays are safe
Section titled “Replays are safe”A write whose values already match what’s stored is a success, not an error.
Ahoy’s underlying pipeline rejects no-change events, but this API treats that as an idempotent write and returns the current record. In practice:
- A retried
PATCHafter a network timeout won’t fail. - A replayed
PUTupsert returns the same record. - A batch containing the same record twice doesn’t error on the second.
That makes at-least-once delivery from your side safe to build on — you don’t need distributed-transaction machinery to avoid double-writes.
Upserts need a genuinely unique property
Section titled “Upserts need a genuinely unique property”matching_property names the property used to find an existing record. It must
be readable, and present and non-null in the body — otherwise you get
request_invalid with
reason: "invalid_matching_property".
If the value matches two or more records, there is no single record to
update, and the API returns conflict with
reason: "ambiguous_match" rather than guessing.
curl -X PUT 'https://api.ahoy.ai/rest/v1/objects/ahoy_contact/records/?matching_property=ahoy_email' \ -H "Authorization: Bearer $AHOY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"properties": {"ahoy_email": ["ada@example.com"], "ahoy_first_name": "Ada", "ahoy_last_name": "Lovelace"}}'An external id from your own system is usually a better matching property than an email address, which people share and change.
Associations are validated before the write
Section titled “Associations are validated before the write”Linking to a target that is unknown, deleted, or invisible to your credential
returns one byte-identical 422 with field: /target_id — the same answer in
all three cases, so a missing record can’t be distinguished from one you simply
can’t see.
Permission is checked before the target is looked up, so a credential without write access learns nothing about whether the target exists.
What can be written at all
Section titled “What can be written at all”Two separate gates apply, in this order:
- The object type’s own rules. Some types are read-only or link-only for
every credential — synced data like meetings and email threads, and Ahoy’s
own generated signals. Failing this returns
permission_deniedwithreason: "type_read_only". Because it’s checked first, the answer is identical for every credential. - Your credential’s grants. Failing this returns the same status with
reason: "write_forbidden".
Read writable_operations from GET /rest/v1/objects/ rather than hard-coding
either. See Objects and properties.
Validation failures
Section titled “Validation failures”A body that doesn’t satisfy the object type’s schema returns 422 with
reason: "schema_invalid" and a field pointer to the offending property. When
more than one field is wrong, an errors array lists every
{field, detail, reason} — so you can fix them all in one pass rather than
discovering them one request at a time.