Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.twine.se/llms.txt

Use this file to discover all available pages before exploring further.

A shape is a JSON object sent in the request body to POST /v1/org/employees/shape. It describes how an Employee should be projected into a custom output payload, instead of returning Twine’s default representation. By default, every employee field is exposed as an array of dated property records. That structure preserves history but is awkward when the integrating system needs only a few specific fields, flattened, renamed, or reshaped to match its own model. A shape removes that friction: each output key is described inline, the response comes back in exactly that form, and no post-processing is needed on the caller’s side. Shapes are inline only. There is no library of saved shapes on the server, and shapes cannot be referenced by name. Every request carries its own shape in the body.

Background: dated properties, tombstones, and streams

Most fields on a Twine entity are not single scalars but lists of dated property values. A dated property is {value, valid_from, id}, and several of them per field encode the history of that field over time. By convention, a dated property with value: null and a non-null valid_from is a tombstone: it signals that the field’s value ended on valid_from - 1 day. Tombstones are markers, not data. The model is append-only, so an end-of-validity is recorded by adding a tombstone rather than mutating the prior entry. The id field on a dated property acts as a stream identifier. Most properties use a single stream, with id: null on every entry. Stream ids only appear for properties whose values need to coexist in parallel at the same point in time - salary supplements, benefit allocations, and, in the rarer cases where they occur, parallel employments. Same-key entries with different ids belong to independent timelines and must not be merged. By default the shape endpoint treats every entry under a given key as a single timeline (newest valid_from wins regardless of id). The $per_id directive is what projects per-stream rows. These conventions are what $historical and $per_id blocks rely on, so they are worth keeping in mind throughout the rest of this page. See Dated Properties for the full data model.

Shape values

Every value in a shape is one of the following forms. Output keys with no $ prefix become keys in the response. Keys with a $ prefix are directives, and only the names listed below are recognised; anything else is rejected at parse time.
FormMeaning
"@<key>"Read the newest applicable dated property value for <key> across all streams.
"$id"Source primary key (UUID). Not a dated property’s stream id - see $per_id for per-stream behaviour.
"$inserted_at"Source inserted_at timestamp.
"$updated_at"Source updated_at timestamp.
{ "$all": "@<key>" }Project every dated property value for <key> as a flat array. No sibling keys allowed in this object.
{ "$historical": true, "$valid_from_field": "<key>", "$valid_to_field": "<key>"?, ... }Expand into an array of rows, one per unique valid_from across the referenced @properties. See Historical blocks below.
{ "$per_id": true, "$id_field": "<key>"?, ... }Expand into an array of rows, one per unique non-null stream id observed across the block’s referenced properties. See Streams ($per_id) below.
{ ...regular keys... }Plain nested object, recursively shaped.
$per_id and $historical may be combined in the same block (a flat (id × valid_from) array) or nested with $per_id on the outside and $historical on the inside (per-stream history grouping). The reverse nesting - $historical on the outside, $per_id on the inside - is rejected at parse time.

@<key> - latest property value

Reads the newest applicable dated property value for <key>. The “newest” entry is the one with the largest valid_from (entries with valid_from: null sort last). When <key> carries multiple streams, all entries are merged and the single newest one wins, regardless of stream id.
{
  "firstName": "@first_name",
  "salary": "@salary_amount"
}

$id, $inserted_at, $updated_at - source metadata

These three directives read identifying metadata from the source record itself, rather than any of its properties. $id is the source entity’s primary key (a UUID for an Employee); it has nothing to do with the per-stream dp.id used by $per_id.
{
  "id": "$id",
  "created": "$inserted_at",
  "updated": "$updated_at"
}

$all - every value of a property

Returns every dated property value for the referenced key as a flat array, dropping the valid_from annotations. Useful when only the value history is needed, not the dates.
{
  "salaryAmounts": { "$all": "@salary_amount" }
}
$all cannot have sibling keys. The object must contain only $all and nothing else.

Plain nested object

Any object that is not a $historical or $all block is treated as a plain nested object and shaped recursively.
{
  "address": {
    "line1": "@address_1_line_1",
    "city": "@address_1_city"
  }
}

Historical blocks

A $historical block expands a single output key into an array of rows, one per unique valid_from across all @property references inside the block. The output key for each row’s valid_from value is given by $valid_from_field. If $valid_to_field is present, each row also carries an end-of-validity date. At each row’s valid_from, every referenced property is resolved by carry-forward: the latest dated property whose valid_from is less than or equal to the row’s valid_from is used. If no such entry exists for a given property, that property’s output key is omitted from the row entirely. This is distinct from “key present, value null” - a null value comes from a tombstone or an explicit nil entry, while an absent key means no entry was applicable at all. Rows are returned in descending order by valid_from (newest first). Rows whose valid_from is null sort last.

Example A - carry-forward, no end date

Source:
{
  "salary_amount": [
    { "valid_from": "2021-01-01", "value": 2000 },
    { "valid_from": "2020-01-01", "value": 1000 }
  ],
  "salary_payout_frequency": [
    { "valid_from": null, "value": "monthly" }
  ]
}
Shape:
{
  "salaries": {
    "$historical": true,
    "$valid_from_field": "fromDate",
    "amount": "@salary_amount",
    "frequency": "@salary_payout_frequency"
  }
}
Response:
{
  "salaries": [
    { "fromDate": "2021-01-01", "amount": 2000, "frequency": "monthly" },
    { "fromDate": "2020-01-01", "amount": 1000, "frequency": "monthly" },
    { "fromDate": null,         "frequency": "monthly" }
  ]
}
The last row has no amount key because no salary_amount entry has valid_from: null - there is nothing to carry forward to that row. frequency, on the other hand, has an entry with valid_from: null and so applies to every row.

Example B - with $valid_to_field, no tombstone

When $valid_to_field is present, each row’s valid_to is computed from the next-newer row’s valid_from - 1 day. The newest row’s valid_to is null, meaning open-ended. Source:
{
  "salary_amount": [
    { "valid_from": "2021-01-01", "value": 2000 },
    { "valid_from": "2020-01-01", "value": 1000 }
  ]
}
Shape:
{
  "salaries": {
    "$historical": true,
    "$valid_from_field": "fromDate",
    "$valid_to_field": "toDate",
    "amount": "@salary_amount"
  }
}
Response:
{
  "salaries": [
    { "fromDate": "2021-01-01", "amount": 2000, "toDate": null },
    { "fromDate": "2020-01-01", "amount": 1000, "toDate": "2020-12-31" }
  ]
}

Example C - joint tombstone consumed

If the newest row in a historical block is a joint tombstone - every referenced @property resolves to null at that valid_from - the row is dropped from the output and consumed as the end marker for the row immediately before it. The prior row’s valid_to becomes tombstone.valid_from - 1 day. Source:
{
  "salary_amount": [
    { "valid_from": "2022-06-01", "value": null },
    { "valid_from": "2021-01-01", "value": 2000 },
    { "valid_from": "2020-01-01", "value": 1000 }
  ],
  "salary_currency": [
    { "valid_from": "2022-06-01", "value": null },
    { "valid_from": "2020-01-01", "value": "USD" }
  ]
}
Shape:
{
  "salaries": {
    "$historical": true,
    "$valid_from_field": "fromDate",
    "$valid_to_field": "toDate",
    "amount": "@salary_amount",
    "currency": "@salary_currency"
  }
}
Without consumption, the output would carry a leading 2022-06-01 row with both amount and currency set to null:
{
  "salaries": [
    { "fromDate": "2022-06-01", "amount": null, "currency": null,  "toDate": null },
    { "fromDate": "2021-01-01", "amount": 2000, "currency": "USD", "toDate": "2022-05-31" },
    { "fromDate": "2020-01-01", "amount": 1000, "currency": "USD", "toDate": "2020-12-31" }
  ]
}
Because both referenced properties tombstone at the same date, that row is consumed and the response is:
{
  "salaries": [
    { "fromDate": "2021-01-01", "amount": 2000, "currency": "USD", "toDate": "2022-05-31" },
    { "fromDate": "2020-01-01", "amount": 1000, "currency": "USD", "toDate": "2020-12-31" }
  ]
}

Example D - partial-nil row survives

A row where some referenced properties are null but at least one is non-null is not a joint tombstone. It survives in the output as a partial-value row, and its valid_to follows the standard rule. Source:
{
  "salary_amount": [
    { "valid_from": "2022-06-01", "value": null },
    { "valid_from": "2020-01-01", "value": 2000 }
  ],
  "salary_currency": [
    { "valid_from": "2020-01-01", "value": "USD" }
  ]
}
Same shape as Example C. Response:
{
  "salaries": [
    { "fromDate": "2022-06-01", "amount": null, "currency": "USD", "toDate": null },
    { "fromDate": "2020-01-01", "amount": 2000, "currency": "USD", "toDate": "2022-05-31" }
  ]
}
salary_amount tombstones at 2022-06-01, but salary_currency carries "USD" forward, so the row is kept.
Per-property end dates are not tracked inside multi-property historical blocks. The $valid_to_field annotation is row-level only. A historical block referencing a single property is what to use when per-property resolution is needed.

Streams ($per_id)

Several has-many properties (employments, salaries, rates, competences, and others) produce multiple parallel streams under a single property key, each tagged with a stream id. Without $per_id, all streams collapse: @employment_start_date returns the newest start date across every employment, regardless of which employment it belongs to. With $per_id, the block becomes an array, one element per unique non-null stream id observed across the block’s referenced properties. Scoping inside a $per_id row. Every @<key> reference in the block is filtered to dated property entries whose id equals the row’s id. A property with no entry under that id is omitted from the row entirely (the output key is not present), rather than being emitted as null. Entries with id: null are not visible inside $per_id rows. Output ordering. Rows are sorted by each stream’s most recent valid_from, newest first. Streams whose newest valid_from is null sort last. $id_field is optional. When set, each row carries the stream id under that output key. When omitted, the id is still used to partition rows but does not appear in the response.

Example - per-stream projection

{
  "employments": {
    "$per_id": true,
    "$id_field": "id",
    "startDate": "@employment_start_date",
    "type": "@employment_type_name"
  }
}
Response:
{
  "employments": [
    { "id": "emp-A", "startDate": "2021-06-01", "type": "Full-time" },
    { "id": "emp-B", "startDate": "2020-01-01", "type": "Part-time" }
  ]
}

Example - $per_id and $historical on the same block

A flat array of (id × valid_from) rows. Tombstones are consumed within their own stream and never bridge across stream ids.
{
  "salaryHistory": {
    "$per_id": true,
    "$id_field": "kind",
    "$historical": true,
    "$valid_from_field": "from",
    "amount": "@salary_amount"
  }
}

Example - nested form (per-stream history)

$per_id on the outside and $historical on the inside groups history under each stream:
{
  "employments": {
    "$per_id": true,
    "$id_field": "id",
    "history": {
      "$historical": true,
      "$valid_from_field": "from",
      "type": "@employment_type_name"
    }
  }
}
Each element in employments is { "id": ..., "history": [...] }, where history is scoped to that one stream’s dated property entries. The reverse nesting - $historical on the outside, $per_id on the inside - is rejected at parse time, because per-valid_from rows have no useful per-stream sub-structure.

Parse-time rejections

A shape is validated in full before evaluation. Errors are aggregated, so all problems are reported at once rather than one at a time. Each rejection returns HTTP 400 with a structured errors array; each entry carries domain: "DataShape", a reason from the table below, and a message containing the path inside the shape where the problem was found.
ReasonCause
unknown_directive_keyA $-prefixed key that is not one of the recognised directives (for example, $frobnicate).
missing_valid_from_fieldA $historical block does not include $valid_from_field.
misplaced_valid_from_field$valid_from_field appears outside a $historical block.
misplaced_valid_to_field$valid_to_field appears outside a $historical block.
misplaced_id_field$id_field appears outside a $per_id block.
duplicate_valid_to_field$valid_from_field and $valid_to_field are set to the same output key, which would clobber the valid_from value.
duplicate_id_field$id_field collides with another output key in the same block, including $valid_from_field or $valid_to_field.
invalid_historical_value$historical is set to anything other than true (including false).
invalid_per_id_value$per_id is set to anything other than true (including false).
invalid_id_field_value$id_field is set to a non-string value.
per_id_inside_historicalA $per_id block is nested inside a pure $historical block. (The supported nesting is $per_id outside, $historical inside.)
invalid_all_with_siblingsAn $all object has additional keys alongside $all.
invalid_valueA field value is neither a @-prefixed string, a known $-directive, nor a JSON object.
unknown_propertyA @<key> reference where <key> is not a known property of the source schema.
max_depth_exceededThe shape nests more than 8 levels deep.
max_keys_exceededThe shape contains more than 200 keys in total (counted across all nested objects).

Authorization

Field-level authorization is enforced on the parsed shape, before evaluation:
  • The role calling /v1/org/employees/shape needs the :list action on :employee.
  • Every @property and { "$all": "@property" } reference is checked against the role’s authorized_fields. Any reference to a field outside that set fails the request with HTTP 403 and a structured error listing the offending property name(s).
This is stricter than the regular employee index endpoint, which silently narrows the response to the fields the role is allowed to see. The shape endpoint fails loudly instead, because partial silent payloads would not match the shape the caller asked for and would be hard to diagnose.

End-to-end example

A complete request and response, combining a top-level @property, the $id and $inserted_at directives, a nested object, a $historical block with $valid_to_field, and an $all projection.
curl -X POST https://api.twine.se/v1/org/employees/shape \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @shape.json
Request body (shape.json):
{
  "shape": {
    "id": "$id",
    "firstName": "@first_name",
    "lastName": "@last_name",
    "address": {
      "line1": "@address_1_line_1",
      "city": "@address_1_city"
    },
    "salaries": {
      "$historical": true,
      "$valid_from_field": "fromDate",
      "$valid_to_field": "toDate",
      "amount": "@salary_amount",
      "currency": "@salary_currency"
    },
    "salaryAmounts": { "$all": "@salary_amount" },
    "latestHireDate": "@employment_hire_date",
    "created": "$inserted_at"
  }
}
Response:
{
  "data": [
    {
      "id": "9b8a4c2e-...-5d1f",
      "firstName": "John",
      "lastName": "Doe",
      "address": {
        "line1": "Storgatan 1",
        "city": "Stockholm"
      },
      "salaries": [
        { "fromDate": "2021-01-01", "amount": 2000, "currency": "USD", "toDate": null },
        { "fromDate": "2020-01-01", "amount": 1000, "currency": "USD", "toDate": "2020-12-31" }
      ],
      "salaryAmounts": [2000, 1000],
      "latestHireDate": "2021-01-01",
      "created": "2021-01-01T00:00:00Z"
    }
  ],
  "pagination": {
    "start_cursor": "...",
    "end_cursor": "...",
    "has_next_page": false,
    "has_previous_page": false
  }
}
The endpoint paginates the same way as the regular employee list endpoint. Each item in data is the result of evaluating the shape against one employee record, in the same order the underlying list endpoint would return.