Fruitback Docs
fruitback.com GitHub
All the docs

Docs Reference REST connector

REST connector

A workspace can connect an address of its own, on the Cloud or on an instance that sets FRUITBACK_SECRETS_KEY. Each note of a site that sends there is posted to that address, signed. Use it to put notes in a tool Fruitback has no connector for.

The notes stay in Fruitback too. Your address only receives: it cannot give a note back to draw its pin. So the pins, the threads and the list are read from Fruitback, as for a site with no connector.

This page is the contract your receiver is written against.

The request

POST to the address you gave, over https, with a JSON body:

{
  "version": 2,
  "event": "note.created",
  "workspace": "ws_3f9a…",
  "site": { "id": "site_81c2…", "origin": "https://staging.acme.dev" },
  "identifier": "FB-12",
  "note": {
    "kind": "fruitback.note",
    "v": 3,
    "text": "The price should say per month.",
    "page": { "url": "https://staging.acme.dev/pricing" }
  }
}
Field What it is
version 2. It moves when a field changes meaning or goes. A new field does not move it.
event note.created. An address that a site calls to start an agent gets agent.requested too (below).
workspace The id of the workspace.
site The id of the site, and its origin.
identifier What Fruitback calls the note. The thread of its pin shows it.
note The whole note, as the widget built it. What the widget collects lists every field.

Version 2 came with 0.4.0: the note is under note, its words in text. Version 1 had it under another key, in the shape of before: a receiver written for it reads nothing from version 2, and is written again for this page. parseNote of @fruitback/shared 0.4.0 reads the note of version 2.

A note can carry files: a capture of the page or of an area, an image, any file. A body then has one more field, attachments, with one entry for each file that still waits in the worker:

"attachments": [
  {
    "name": "capture.png",
    "type": "image/png",
    "size": 48213,
    "url": "https://api.fruitback.example/attachments/att_5c1e…?expires=1792569600&signature=9b2f…",
    "expiresAt": "2026-10-21T11:00:00.000Z"
  }
]

Read each file at its url before expiresAt, and keep it: Fruitback does not. The address answers the bytes with a GET and no credential, for one hour from the attempt that carries it, and 404 after. The worker deletes the file one hour after your 2xx, and 7 days after it came at the latest. Each attempt signs new addresses, so the body of a second attempt differs from the first; a file whose 7 days ended is left out, and the note comes without it. type is the type the worker checked: an image (PNG, JPEG, WebP, GIF), a PDF, a text (plain, CSV, JSON), or application/octet-stream for any other file, whatever its sender said. Never open a file in a browser on its type: serve it as a download.

And these headers:

Header What it is
Content-Type application/json
X-Fruitback-Delivery The id of this delivery. It is the same at each attempt for one note.
X-Fruitback-Timestamp When this attempt was signed, in seconds since 1970.
X-Fruitback-Signature sha256= and the signature of this attempt, in hexadecimal. See below.

A call that starts an agent

A site can call an address of the workspace when a note arrives, to start an agent of the team (Connect an AI agent). That call is a request of this page, with the same headers, signature and attempts, and event: "agent.requested". Its body holds reason, the workspace, the site, identifier and note.id, and no note: neither its text nor its files. Answer it as any other.

Check the signature

The signature is the HMAC-SHA256, with your secret, of the timestamp, a dot and the body as it arrived, byte for byte. Check it before you read the body:

import { createHmac, timingSafeEqual } from 'node:crypto';

function isFromFruitback(secret, headers, rawBody) {
  const timestamp = headers['x-fruitback-timestamp'];
  const expected = `sha256=${createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')}`;
  const given = headers['x-fruitback-signature'] ?? '';
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;

  return fresh && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
  • Sign the bytes you received. A body parsed and written again is not the same bytes.
  • Refuse an old timestamp. Five minutes is enough. The timestamp is signed, so a copy of a request cannot be sent again later with a new time.
  • Compare in constant time.

To try it by hand:

printf '1760000000.{"version":1}' | openssl dgst -sha256 -hmac 'a-secret-of-enough-length'
# a73ee69fbd433aae0f6bf90c540f89584463d5c2ee12643fc69c36fd7195c38c

Answer, and what happens when you do not

Answer any 2xx when the note is yours. The body of your answer is not read.

Anything else is a delivery that did not arrive: another status, a redirect (it is not followed), no answer in 10 seconds. Fruitback then tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours: seven attempts in about a day and a half. After the last one it stops, and the delivery waits for somebody to ask again. Asking starts the seven attempts again. A delivery nobody asks for is removed after 30 days.

A 508 stops at once. Fruitback itself answers 508 to a request that carries X-Fruitback-Delivery: an address that is a Fruitback worker would send the note back to its sender. There is no second attempt, and the delivery waits for somebody to ask again. If your receiver passes a request on to a Fruitback worker, do not pass that header on.

One note can arrive twice. Your answer can be lost on the way back, and the next attempt then sends the note again. X-Fruitback-Delivery is the same for both: keep the ids you took, and answer 2xx to one you already have.

The first attempt usually leaves within about 15 seconds of the note. The reporter does not wait for it: the widget is answered when Fruitback keeps the note.

The address and the secret

  • The address is https://, with no name and password in it. Fruitback does not call localhost, the machine it runs on or a private network, and it checks that when the name is resolved, at each attempt.
  • The secret is 16 to 256 characters with no space. Give none and Fruitback makes one, and shows it once. Fruitback keeps the address and the secret encrypted, and answers neither again.
  • To change the address or the secret, disconnect the connector and connect a new one. The deliveries that waited go with the old one.