---
title: "API Doc: Dragonfruit Session Events API"
description: API reference for sending session START and STOP taps from the Victra session button app to Dragonfruit.
---

[Skip to content](https://help.dragonfruit.ai/helpcenter/dragonfruit-session-events-api#main-content)

English

Show submenu for translations

[More support](https://help.dragonfruit.ai/helpcenter/kb-tickets/new?hsLang=en) [Customer portal](https://support.dragonfruit.ai/tickets?hsLang=en)

[![dragonfruit-logo-Mar-20-2025-06-13-51-8141-AM](https://help.dragonfruit.ai/hs-fs/hubfs/dragonfruit-logo-Mar-20-2025-06-13-51-8141-AM.png?width=250&height=40&name=dragonfruit-logo-Mar-20-2025-06-13-51-8141-AM.png)](https://help.dragonfruit.ai/helpcenter?hsLang=en)

Open main navigation

Close main navigation

- English
  
  Show submenu for translations
- [More support](https://help.dragonfruit.ai/helpcenter/kb-tickets/new)
- [Customer portal](https://support.dragonfruit.ai/tickets)

 How can we help you?

- There are no suggestions because the search field is empty.

1. [Help Center](https://help.dragonfruit.ai/helpcenter?hsLang=en)
2. [Traffic Analysis](https://help.dragonfruit.ai/helpcenter/traffic-analysis?hsLang=en)
3. [API](https://help.dragonfruit.ai/helpcenter/traffic-analysis?hsLang=en#api)

# API Doc: Dragonfruit Session Events API

## API Reference doc for the Session button app. Version: 1, Last Updated: 2026-10-22

Your store app sends Dragonfruit one HTTPS request for each START or STOP tap. Dragonfruit stores the session and then takes a still image from each camera at the store, at the moment of the tap. This page covers what the app needs: logging in, sending taps, retrying safely and handling errors.

### **At a glance**

| **Item** | **Value** |
| --- | --- |
| Production base URL | `https://df-services.dragonfruit.ai` |
| Send a tap | `POST /customer/{customer_id}/sites/{site_id}/event` |
| List session labels | `GET /customer/{customer_id}/site_event_labels` |
| Log in / refresh tokens | `POST /login` · `POST /get_new_tokens` |
| Authentication | `Authorization: Bearer <IdToken>` |
| Format | JSON, UTF-8, at most 16 KiB per request |
| Rate limit | 50 taps per second per customer, with bursts up to 200 |
| Safe-retry window | 7 days: a repeated tap within 7 days returns the original answer |

### **1. How a session works**

1. An employee taps **START**. The app sends a START request. Its `occurred_at` is the time of the tap.
2. Dragonfruit answers **202** with a `session_id`. The app saves it.
3. The employee taps **STOP**. The app sends a STOP request with that `session_id`.
4. Dragonfruit answers **202**. The session is now complete, with its duration.

After it answers a START or an employee's STOP, Dragonfruit asks the store's Dragonfruit box for one still image from each camera at the store, taken at `occurred_at`. This work happens after the response, so it never slows the tap down.

#### **What a 202 means**

A 202 means the tap is safely stored. It does not mean the images are ready yet. The images come from the store's recording at `occurred_at`, not from the time the request arrived. So a tap that was sent late still gets images from the right moment. Images can only be taken up to 110 minutes after `occurred_at`. A tap that arrives later than that is still stored, but it gets no images.

### **2. What you need**

Every request needs these values. Dragonfruit provides them, for production and for staging.

- **Customer ID.** The number at the start of every URL.
- **Site ID for each store.** Dragonfruit's number for the store (a whole number, used in the URL). Dragonfruit provides a list of site IDs with your store numbers (`external_site_id`, sent in the body). Each tap carries both, and the API rejects a tap when they do not match.
- **A store-device login.** See section 3, Logging in.

### **3. Logging in**

#### **The store-device login**

The app uses one dedicated Dragonfruit login. This login can do only two things: send taps and read the label list. It cannot read sessions, images, users or any other data in your Dragonfruit account. It works for all of your stores, so keep its password and tokens secret.

How the login is created:

1. You give Dragonfruit an email mailbox for the login. The mailbox's domain must not use single sign-on (SSO) with Dragonfruit, because the app logs in with a password.
2. Dragonfruit sends an invite to that mailbox. You accept it and set a password.
3. Dragonfruit gives the login the store-device role.
4. The app logs in with that email and password, as shown below.

#### **Get tokens**

##### `POST /login`

```
POST /loginContent-Type: application/json{"email": "<device login email>", "password": "<password>"}
```

A successful login returns `"success": true`, and `data` holds the tokens:

```
{  "success": true,  "data": {    "IdToken": "eyJraWQiOi...",    "AccessToken": "eyJraWQiOi...",    "RefreshToken": "eyJjdHkiOi...",    "ExpiresIn": 3600  }}
```

A failed login can still return HTTP 200 with `"success": false`, so always check the `success` field.

#### **Send the token with each request**

Send the **IdToken**, not the AccessToken:

```
Authorization: Bearer <IdToken>
```

#### **Refresh the token**

The IdToken expires after `ExpiresIn` seconds (about one hour). Use the returned value instead of a fixed lifetime. Before it expires, get a new one with the refresh token:

##### `POST /get_new_tokens`

```
POST /get_new_tokensContent-Type: application/json{"refreshToken": "<RefreshToken>"}
```

Use the new `data.IdToken` from the response. If the response does not include a new refresh token, keep using the one you have. This endpoint can also report a failure as `"success": false`, so check that field.

#### **When a token stops working**

1. A tap returns **401**: refresh the token once and send the same tap again.
2. The refresh fails: log in again with the email and password.
3. The login fails too: stop and alert your team. Keep the taps in your queue so they can be sent once the login works again.

### **4. Send a START**

##### `POST /customer/{customer_id}/sites/{site_id}/event`

```
POST /customer/2221/sites/731/eventAuthorization: Bearer <IdToken>Content-Type: application/jsonUser-Agent: VictraSessions/1.0.3 (iOS 19.0){  "event_type": "START",  "occurred_at": "2026-10-19T09:59:00.123-04:00",  "external_site_id": "4182",  "employee_id": "00100482",  "label": "SALE"}
```

###### **Path**

| **Part** | **Meaning** |
| --- | --- |
| `customer_id` | Your Dragonfruit customer ID (whole number). |
| `site_id` | The Dragonfruit site ID of the store (whole number), from the store list. |

###### **Headers**

| **Header** | **Required** | **Meaning** |
| --- | --- | --- |
| `Authorization` | Yes | `Bearer <IdToken>` |
| `Content-Type` | Yes | `application/json` |
| `User-Agent` | Recommended | The app name, version and platform, in this format: `VictraSessions/1.0.3 (iOS 19.0)`. Dragonfruit reads the app version from it. At most 256 characters. |
| `Idempotency-Key` | Optional | A UUID. Not needed for safe retries; see section 9, Retries. |

###### **Body**

| **Field** | **Required** | **Meaning** |
| --- | --- | --- |
| `event_type` | Yes | `"START"` |
| `occurred_at` | Yes | The time of the tap, not the time of sending. ISO 8601 with exactly three decimal places and a timezone offset, for example `2026-10-19T09:59:00.123-04:00` or `2026-10-19T13:59:00.123Z`. |
| `external_site_id` | Yes | Your store number, exactly as in the store list. It must match the store in the URL. |
| `employee_id` | Yes | Your employee number. Not an email address or a name. Leading zeroes are kept. |
| `label` | Optional | The type of interaction, as a label code, for example `SALE`. See section 7, Session labels. |

A START must not include `session_id` or `stop_reason`. Dragonfruit creates the session ID.

#### **Response: 202 Accepted**

```
{  "success": true,  "data": {    "event_id": "sev_01K7XP0C000000000000000001",    "session_id": "ses_01K7XP0C000000000000000002",    "status": "accepted",    "accepted_at": "2026-10-19T13:59:00.456Z",    "accepted_by": "cloud",    "site_id": 731,    "external_site_id": "4182"  }}
```

| **Field** | **Meaning** |
| --- | --- |
| `event_id` | Dragonfruit's ID for this tap. |
| `session_id` | Dragonfruit's ID for the session. **Save it.** The STOP must send it. |
| `status` | Always `accepted`. |
| `accepted_at` | When Dragonfruit stored the tap, in UTC. Compare it with your tap time to measure delivery delay. |
| `accepted_by` | Always `cloud` in this version. |
| `site_id`, `external_site_id` | The store the tap was recorded for. |

The values in these examples (site ID, store number, employee ID, event ID and session ID) are made up. Use the site IDs and store numbers from your store list, and always use the IDs Dragonfruit returns.

### **5. Send a STOP**

##### `POST /customer/{customer_id}/sites/{site_id}/event`

```
POST /customer/2221/sites/731/eventAuthorization: Bearer <IdToken>Content-Type: application/jsonUser-Agent: VictraSessions/1.0.3 (iOS 19.0){  "event_type": "STOP",  "occurred_at": "2026-10-19T10:04:30.123-04:00",  "external_site_id": "4182",  "session_id": "ses_01K7XP0C000000000000000002",  "stop_reason": "EMPLOYEE_TAP"}
```

Use the same URL and headers as START.

###### **Body**

| **Field** | **Required** | **Meaning** |
| --- | --- | --- |
| `event_type` | Yes | `"STOP"` |
| `occurred_at` | Yes | The time of the tap, in the same format as START. It cannot be earlier than the START. |
| `external_site_id` | Yes | Your store number. It must match the store in the URL. |
| `session_id` | Yes | The `session_id` from the START response. |
| `stop_reason` | Optional | Why the session ended. Defaults to `EMPLOYEE_TAP`. See the table below. |
| `label` | Optional | Changes the session's label. If you leave it out, the session keeps the label from START. |

A STOP must not include `employee_id`. The session already knows the employee.

#### **Stop reasons**

| **`stop_reason`** | **Who ended the session** | **Duration and images** |
| --- | --- | --- |
| `EMPLOYEE_TAP` (default) | The employee tapped STOP. | Dragonfruit records the measured duration and takes images. |
| `MANUAL_CLOSE` | A manager closed the session. | Dragonfruit records that the session closed, but with no measured duration and no images. |
| `AUTO_CLOSE_TIMEOUT` | Your system closed it after a timeout. | Dragonfruit records that the session closed, but with no measured duration and no images. |
| `END_OF_DAY` | Your system closed it at the end of the day. | Dragonfruit records that the session closed, but with no measured duration and no images. |

#### **Response: 202 Accepted**

The same shape as the START response. `session_id` is the session you closed, and `event_id` is the new ID for the STOP tap.

### **6. Field rules**

- **`occurred_at`** must have exactly three decimal places (milliseconds) and a timezone offset (`Z` or `±hh:mm`). Times in the past are allowed. A time more than five minutes in the future is rejected, so check that store devices keep their clocks correct.
- **Identifiers** (`external_site_id`, `employee_id`) are treated as exact text. Leading zeroes are kept, so `00100482` and `100482` are different employees. Each can be at most 128 characters. They cannot be empty or contain `|`, control characters or invisible characters (for example a zero-width space).
- **`employee_id`** also cannot contain spaces.
- **`session_id`** must be the exact value from the START response.
- **Unknown fields are rejected** with a 400. Send only the fields listed on this page.
- **The body** must be a JSON object of at most 16 KiB, sent with `Content-Type: application/json`.

### **7. Session labels**

A label says what kind of interaction the session was. Labels are codes from a list that Dragonfruit keeps for your account. The app can read the list with the same store-device login:

##### `GET /customer/{customer_id}/site_event_labels`

```
{  "success": true,  "data": [    {"code": "SALE", "displayName": "New sale",     "active": true, "sortOrder": 0},    {"code": "UPGRADE", "displayName": "Upgrade",     "active": true, "sortOrder": 1},    {"code": "TECH_SUPPORT", "displayName": "Tech support",     "active": true, "sortOrder": 2}  ]}
```

- The list above is the starting list. Labels can be added, renamed or retired later without changing the API.
- The list also includes retired labels (`"active": false`). Show only active labels in the app. A tap with a retired label is rejected.
- A code is 1 to 32 characters: uppercase letters, digits and underscores. A code never changes; only its display name can.
- Sorting: by `sortOrder`, then by `code`.

### **8. Session rules**

- A STOP must name a session that started at the same store. Otherwise it returns 404 `session_not_found`.
- A STOP cannot be earlier than its START. It returns 400.
- A session has one STOP. A second, different STOP returns 409 `session_closed`. One exception: if your system closed the session (`MANUAL_CLOSE`, `AUTO_CLOSE_TIMEOUT` or `END_OF_DAY`) and the employee's own STOP arrives later but has an earlier `occurred_at`, the employee's STOP replaces the system close. This covers a STOP that sat in an offline queue while the system close arrived first.
- Dragonfruit never closes a session by itself. A START with no STOP stays open. Your auto-close is the only timeout, so send a STOP with `AUTO_CLOSE_TIMEOUT` or `END_OF_DAY` when it runs. That STOP needs the `session_id` from the START response.
- Dragonfruit does not stop an employee from having more than one open session at the same time.
- Taps cannot be deleted or edited through the API.

### **9. Retries, duplicates and offline taps**

#### **Retry with exactly the same body**

If a request times out or fails with a retryable error, send the same request again, with the same body. Keep the original `occurred_at`; never replace it with the time of the retry. Dragonfruit recognizes the repeat and does not create a second session.

#### **How Dragonfruit spots a repeat**

Two taps are the same tap when all of these match:

- START: event type, customer, site, `employee_id` and `occurred_at` (to the millisecond).
- STOP: event type, customer, site, `session_id`, `stop_reason` and `occurred_at` (to the millisecond).

The label is not part of the match: the first accepted tap wins. A repeat returns **HTTP 200** with the original response body, including the original `event_id`, `session_id` and `accepted_at`. Treat it as success. Dragonfruit remembers taps for 7 days, so a repeat after that is treated as a new tap.

#### **Idempotency-Key header (optional)**

You do not need this header. If you do send it, it must be a UUID, and it applies across your whole account. Sending the same key again with a different body or a different site returns 409 `idempotency_key_conflict`. The content match above still works whether or not you send the header.

#### **Offline taps**

- Send queued taps in the order they happened, each with its original `occurred_at`. No "delayed" flag is needed. Dragonfruit compares `occurred_at` with the arrival time itself.
- A STOP needs the `session_id` from its START. If the START is still in the queue, hold the STOP until the START is accepted.
- Images can only be taken up to 110 minutes after `occurred_at`. Send queued taps as soon as the device is back online. Taps that arrive later are still stored, but get no images.

### **10. Errors**

Errors use real HTTP status codes and this body:

```
{  "success": false,  "data": {    "type": "site_mismatch",    "message": "external_site_id does not match this site"  }}
```

Use `type` in your code and logs. `message` is for people and may change.

| **Status** | **`type`** | **Meaning** | **What the app should do** |
| --- | --- | --- | --- |
| 202 | — | The tap is stored. | Done. On START, save `session_id`. |
| 200 | — | This tap was already stored (a repeat). | Treat as success. The body is the original response. |
| 400 | `invalid_payload` | The body is not valid JSON, is too large, or a field has a wrong value or format. Also used when the `Idempotency-Key` header is not a UUID, and when a STOP is earlier than its START. | Do not retry. The same request will fail again. Log it and fix the data or the app. |
| 400 | `unknown_field` | The body has a field this API does not accept. | Do not retry. The same request will fail again. Log it and fix the data or the app. |
| 400 | `session_id_not_allowed` | A START included `session_id`. | Do not retry. The same request will fail again. Log it and fix the data or the app. |
| 400 | `occurred_at_in_future` | `occurred_at` is more than five minutes ahead of Dragonfruit's clock. Check the device clock. | Do not retry. The same request will fail again. Log it and fix the data or the app. |
| 400 | `site_mismatch` | `external_site_id` does not match the store in the URL. | Do not retry. The same request will fail again. Log it and fix the data or the app. |
| 400 | `unknown_label` | The label is not in your label list. | Do not retry. The same request will fail again. Log it and fix the data or the app. |
| 400 | `label_retired` | The label has been retired. | Do not retry. The same request will fail again. Log it and fix the data or the app. |
| 401 | `unauthorized` | The token is missing, invalid or expired. | Refresh the token once and retry. See section 3, When a token stops working. |
| 403 | `PERMISSION_DENIED` | The login does not belong to this customer, or it lacks permission to send taps. | Do not retry. Alert your team and contact Dragonfruit. |
| 403 | `site_events_not_enabled` | Session events are not switched on for your account yet. Nothing was stored. | Contact Dragonfruit. |
| 404 | `site_not_found` | The `site_id` in the URL is not one of your stores. | Do not retry. Set the tap aside and report it. Never guess a different site or session. |
| 404 | `session_not_found` | The STOP's `session_id` does not exist at this store, or the session is too old and has been deleted. | Do not retry. Set the tap aside and report it. Never guess a different site or session. |
| 409 | `session_closed` | The session already has a STOP. | Do not retry. The session is already closed. |
| 409 | `idempotency_key_conflict` | This `Idempotency-Key` was already used for a different request. | Do not retry. This is a bug in the app. |
| 429 | `rate_limited` | Your account sent more than its request limit. | Wait for the `Retry-After` seconds, then retry the same request, with backoff. |
| 500 | `internal_error` | A problem on Dragonfruit's side that retries will not fix until Dragonfruit corrects it. | Keep the tap queued and contact Dragonfruit. Retrying is safe but will keep failing until the fix. |
| 503 | `unavailable` | Dragonfruit is briefly unavailable. | Wait for the `Retry-After` seconds, then retry the same request, with backoff. |

Both 429 and 503 return the header `Retry-After: 1`. A network timeout with no response is also safe to retry with the same body.

The rate limit is per customer: 50 taps per second, with bursts up to 200. The label list has its own separate limit, so reading labels never uses up the tap limit.

### **11. Full example with curl**

This runs a whole session against staging. Replace the values in angle brackets with the ones Dragonfruit sends you. It needs `curl` and `jq`.

```
BASE=https://stage-df-services.dragonfruit.aiCUSTOMER=<staging customer_id>SITE=<staging site_id>STORE=<staging external_site_id># 1. Log in and keep the IdTokenTOKEN=$(curl -s -X POST "$BASE/login" \  -H 'Content-Type: application/json' \  -d '{"email": "<device login email>", "password": "<password>"}' \  | jq -r '.data.IdToken')# 2. Read the label listcurl -s "$BASE/customer/$CUSTOMER/site_event_labels" \  -H "Authorization: Bearer $TOKEN" | jq# 3. START, and keep the session_idSESSION=$(curl -s -X POST "$BASE/customer/$CUSTOMER/sites/$SITE/event" \  -H "Authorization: Bearer $TOKEN" \  -H 'Content-Type: application/json' \  -H 'User-Agent: VictraSessions/1.0.3 (iOS 19.0)' \  -d '{"event_type": "START",       "occurred_at": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'",       "external_site_id": "'"$STORE"'",       "employee_id": "TEST-0001",       "label": "SALE"}' \  | jq -r '.data.session_id')echo "session: $SESSION"# 4. STOP the same sessioncurl -s -X POST "$BASE/customer/$CUSTOMER/sites/$SITE/event" \  -H "Authorization: Bearer $TOKEN" \  -H 'Content-Type: application/json' \  -H 'User-Agent: VictraSessions/1.0.3 (iOS 19.0)' \  -d '{"event_type": "STOP",       "occurred_at": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'",       "external_site_id": "'"$STORE"'",       "session_id": "'"$SESSION"'"}' | jq
```

Sending step 3 again with the same body returns 200 and the same `session_id`. The example puts the current time in `occurred_at`. A real app sets it at the moment of the tap and keeps it for every retry.

### **12. Changes from Victra's v1.0 requirements**

This API follows Dragonfruit's existing API conventions, so some parts differ from the v1.0 requirements document (5 Oct 2026). The table maps each difference.

| **Victra v1.0** | **This API** | **Why** |
| --- | --- | --- |
| `POST /v1/sites/{df_site_id}/session-events` | `POST /customer/{customer_id}/sites/{site_id}/event` | Matches the path style of Dragonfruit's other customer APIs. |
| `df_site_id` like `"DF-004182"` | `site_id` is a whole number, for example `731`, from the store list | Dragonfruit site IDs are numbers. Every request still carries both IDs: `site_id` in the URL and your store number in `external_site_id`. |
| `session_id` made on the device | Dragonfruit creates `session_id` at START and returns it. STOP sends it back. | Lets Dragonfruit reject a STOP for a session that does not exist. |
| `event_id` made on the device, sent as `Idempotency-Key` | No `event_id` in the request. Dragonfruit creates it. Repeats are matched on the tap's content; the header is optional and must be a UUID. | The app has no extra ID to manage, and a retry is safe even if the app loses the key. |
| `site` and `actor` objects | Flat fields `external_site_id` and `employee_id` | Simpler body. |
| `actor.source` | Removed | Dragonfruit identifies the sender from the login. |
| `delivery.is_delayed`, `queued_at` | Removed. Send the original `occurred_at`. | Dragonfruit works out the delay from `occurred_at` and the arrival time. |
| `delivery.is_assumed`, `assumed_reason` | `stop_reason` on STOP | The close reason belongs to the session, not to the delivery. The same three reasons are supported. |
| `app_version` in the body | `User-Agent` header, for example `VictraSessions/1.0.3 (iOS 19.0)` | The standard place for client details. |
| Response without a wrapper | Response wrapped as `{"success": ..., "data": {...}}` | Same wrapper as Dragonfruit's other APIs. |
| 404 for an unknown store number | 400 `site_mismatch` when the store number does not match the site; 404 `site_not_found` when the site is not yours | The URL picks the site, and the body's store number is a cross-check. |
| Error table in section 6 | Same, plus 404 `session_not_found` and 409 `session_closed` | STOP now refers to a session that Dragonfruit created. |

Sending any of the removed v1.0 fields (`event_id`, `site`, `actor`, `delivery`, `app_version`) returns 400 `unknown_field`. Sending a non-UUID value such as `evt_6c1f90a37b` as `Idempotency-Key` returns 400 `invalid_payload`.

[![dragonfruit-logo-Mar-20-2025-06-13-51-8141-AM](https://help.dragonfruit.ai/hs-fs/hubfs/dragonfruit-logo-Mar-20-2025-06-13-51-8141-AM.png?width=150&height=24&name=dragonfruit-logo-Mar-20-2025-06-13-51-8141-AM.png "dragonfruit-logo-Mar-20-2025-06-13-51-8141-AM")](https://www.dragonfruit.ai/)

Menlo Park, CA, United States

<https://in.linkedin.com/company/dragonfruit-ai> [mailto:contact@dragonfruit.ai](mailto:contact@dragonfruit.ai)

Copyright © 2025, Dragonfruit AI, Inc