Working with Events

Configure event-driven automations — triggers, conditions, and actions like webhooks and email.

Working with events

Events are the automation engine of the Surfact platform. An event watches your variables for a condition (a trigger) and, when it's met, fires one or more actions — such as sending a webhook or an email. Events are managed through the v2.0 API (base URL https://api.surfact.com/api/v2.0/).

GoalEndpointMethod
List all events/events/GET
Create an event/events/POST
Retrieve / update / delete an event/events/{event_id}GET / PATCH / DELETE
Read execution logs for an event/events/{event_id}/logsGET

Full schemas are in the API Reference; this guide explains how the pieces fit together.

Anatomy of an event

An event is made of triggers and actions.

Triggers

Triggers are organized as an array of arrays. Each inner array is a trigger group, and the event fires when any group is satisfied — the outer array combines with OR.

So a two-group event like this fires on either condition:

[
  [ { "type": "last_value", "condition": { "value": "-18", "operator": "<" }, "entity": { "type": "variable", "value": [{ "id": "<temp_id>" }], "operator": "or" } } ],
  [ { "type": "last_value", "condition": { "value": "-15", "operator": ">" }, "entity": { "type": "variable", "value": [{ "id": "<temp_id>" }], "operator": "or" } } ]
]

That is the shape to use for an out-of-band alert: one group for "too cold", one for "too warm", on the same variable.

Each trigger has a type, a condition and an entity (the variable or device group being watched):

{
  "type": "last_value",
  "condition": { "value": "-15", "operator": ">", "delay": 0 },
  "entity": {
    "type": "variable",
    "value": [{ "id": "<temperature_variable_id>" }],
    "operator": "or"
  }
}

Valid trigger type values are last_value, position, inactive, schedule and context.

The condition shape depends on the trigger type

There is no single condition schema — each trigger type stores a different set of keys:

Trigger typeStored conditionNotes
last_value{ "type": "value", "value": -15.0, "operator": "<", "delay": 0.0 }value is a number, delay a float
position{ "type": "value", "value": "GEOMETRYCOLLECTION(POLYGON((…)))", "operator": "out", "delay": 0.0 }value is a WKT geometry string; the operator is in or out
inactive{ "unit": "H", "value": 3.0 }How long silence is tolerated. No operator, no delay, no type key at all
📘

Threshold condition values come back as numbers

On a last_value trigger, send "value": "-15" as a string and the stored trigger reads "value": -15.0, with a "type": "value" key added by the platform and "delay" normalised to a float (0.0). That is normal — don't treat it as a failed write. A position condition keeps its WKT string as-is, and an inactive condition gets no type key.

Watching a whole device group

entity.type is either variable or device_group. Set it to device_group to watch every device in a group rather than one variable. In the group form each value item names the group and the variable label to watch:

{
  "type": "inactive",
  "condition": { "unit": "H", "value": 3 },
  "entity": {
    "type": "device_group",
    "value": [
      {
        "id": "<device_group_id>",
        "variable_label": "temperature",
        "deviceGroup_name": "<device_group_name>",
        "isMultipleGlobalEvent": false
      }
    ],
    "operator": "or"
  }
}

variable_label is what makes a group trigger interpretable — without it there is nothing to say which of the group's variables is being watched. Events built this way come back with "isGlobalEvent": true. In the variable form, each value item carries only id.

Actions

Actions run when the triggers are satisfied. The type determines what happens:

{
  "name": "Notify the cold-chain desk",
  "type": "web-hook",
  "data": {
    "url": "https://ops.example.com/surfact-hook",
    "method": "POST",
    "headers": {},
    "payload": "{}"
  },
  "back_to_normal": false
}

Valid action type values are email, sms, telegram, whatsapp, voice, web-hook, slack-webhook, set-variable, user, contact, incident, ubifunction, particle-webhook and ttn-webhook.

❗️

It is web-hook, not webhook

The hyphen is required. "type": "webhook" is rejected with 400 Validation Error"webhook" is not a valid choice.

❗️

data may not be empty

Every action needs a populated data object; {} is rejected. For a web-hook action, data.payload must be a string, not an object — send "{}", not {}.

FieldDescription
typeRequired. One of the action types listed above
dataRequired. The action's configuration. Must not be empty
nameDisplay name for the action
back_to_normalWhether to also fire when the variable returns to normal

Repetition is controlled at the event level rather than per action, with cooldownPeriod and retriggerWhileActive.

🚧

repeatAction, maxRepetitions and repeatInterval are not stored

These are accepted in a create or update request without complaint, but they are silently discarded — a created action returns only type, name, data, back_to_normal and idGroupAction. Don't rely on them to cap how often an alert fires; use the event's cooldownPeriod instead.

Creating an event

Alert the cold-chain desk when a frozen reefer climbs above −15 °C:

curl -X POST 'https://api.surfact.com/api/v2.0/events/' \
  -H 'Content-Type: application/json' \
  -H 'X-Auth-Token: your_token_here' \
  -d '{
    "label": "frozen-breach-nordic",
    "name": "Frozen Breach — Nordic Fleet",
    "description": "Fires when a frozen reefer rises above -15 C",
    "isActive": true,
    "triggers": [
      [
        {
          "type": "last_value",
          "condition": { "value": "-15", "operator": ">", "delay": 0 },
          "entity": { "type": "variable", "value": [{ "id": "<temperature_variable_id>" }], "operator": "or" }
        }
      ]
    ],
    "actions": [
      {
        "name": "Notify the cold-chain desk",
        "type": "web-hook",
        "data": {
          "url": "https://ops.example.com/surfact-hook",
          "method": "POST",
          "headers": {},
          "payload": "{}"
        },
        "back_to_normal": false
      }
    ],
    "activeDates": {
      "dates": [
        [["00:00", "23:59"]], [["00:00", "23:59"]], [["00:00", "23:59"]],
        [["00:00", "23:59"]], [["00:00", "23:59"]], [["00:00", "23:59"]],
        [["00:00", "23:59"]]
      ],
      "timezone": "Europe/Oslo"
    },
    "isGlobalEvent": false
  }'

A successful call returns 201 with the stored event, including its id.

name, triggers, actions and activeDates are all required — omitting activeDates returns 400 with {"activeDates": ["This field is required."]}. label, description, isActive, isGlobalEvent, cooldownPeriod, retriggerWhileActive, tags and organization are optional.

📘

activeDates.dates is a seven-element weekday array

dates holds one entry per weekday, and each entry is a list of ["HH:MM", "HH:MM"] windows — an array of arrays of two-element string arrays, not an array of strings. The value above is the always-on form (00:0023:59, seven days) that every event on this account stores. To keep an alert quiet outside the shift, narrow the window on the days that matter, e.g. [["06:00", "18:00"]]. timezone is an IANA name such as Europe/Oslo.

❗️

Events never return an organization

You can send organization when creating an event — it is on the write serializer — but you can never read it back. It is absent from every event returned by GET /events/ and GET /events/{event_id}, and requesting it with ?fields=organization returns an empty object for every row. So don't rely on the API to tell you which organization an event belongs to.

The ?organization= filter on /events/ does work, but only with the internal numeric organization id (for example ?organization=30) — an organization object id or a ~label is rejected with 400 Validation Error, "Select a valid choice."

Inspecting event logs

Every time an event evaluates or fires, it records a log entry. Retrieve them with:

curl -X GET 'https://api.surfact.com/api/v2.0/events/<event_id>/logs' \
  -H 'X-Auth-Token: your_token_here'

Each entry carries a logType (event_create, event_update, and the firing types), a human-readable message, a context object holding a snapshot of the event, and a createdAt timestamp:

{
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 818171,
      "event": "6a4f67917585e9f46d6abf26",
      "logType": "event_create",
      "message": "Event \"Frozen Breach — Nordic Fleet\" created",
      "context": { "id": "6a4f67917585e9f46d6abf26", "name": "Frozen Breach — Nordic Fleet" },
      "createdAt": "2026-09-03T12:28:56.393353Z"
    }
  ]
}
❗️

createdAt here is an ISO 8601 string

Event-log timestamps are not the POSIX milliseconds used for data points. Elsewhere in the API createdAt is also ISO 8601, while lastActivity and dot timestamp values are POSIX milliseconds. Check the field before parsing.

Use the logs to confirm an automation is firing as expected and to debug conditions that aren't behaving.

📘

Event logs are paginated without a count

Unlike every other paginated endpoint, this envelope has only next, previous and results — there is no count. Page through with next rather than computing pages from a total.

Connecting to webhooks

A web-hook action is the bridge between events and your own systems. When the event fires, Surfact POSTs the data point to the url in the action's data. See Webhooks for the payload format and setup.


Did this page help you?