Reminders
Chasing is the part of Syllevo an accountant is most nervous about, because the messages go out in their name, to their clients, without them in the room. These routes are the controls for that: what is scheduled today, who receives it, how often, and how to stop it.
Reminders are sent by the engine on a schedule. There is no route that composes a message, and there is no route that sends arbitrary mail. The only send route here asks for the next scheduled touch to happen now, and it refuses more often than it agrees.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/reminders/sending-today |
Any member |
| GET | /v1/reminders/settings |
Admin |
| PUT | /v1/reminders/settings |
Admin |
| GET | /v1/profiles/{profileId}/reminders |
View |
| GET | /v1/profiles/{profileId}/reminders/history |
View |
| POST | /v1/profiles/{profileId}/reminders/pause |
SendReminders |
| POST | /v1/profiles/{profileId}/reminders/resume |
SendReminders |
| POST | /v1/profiles/{profileId}/reminders/send-now |
SendReminders |
| GET | /v1/profiles/{profileId}/reminders/settings |
ManageSettings |
| PUT | /v1/profiles/{profileId}/reminders/settings |
ManageSettings |
| DELETE | /v1/profiles/{profileId}/reminders/settings |
ManageSettings |
| GET | /v1/profiles/{profileId}/contacts |
SendReminders |
| POST | /v1/profiles/{profileId}/contacts |
SendReminders |
| PATCH | /v1/profiles/{profileId}/contacts/{contactId} |
SendReminders |
The vocabulary
Four words, and everything on this page is built from them.
- A sequence is one client's chasing for one calendar month. It has a state, a number of touches it is allowed to spend, and a list of the touches spent so far.
- A touch is one message, scheduled or sent. A sequence with three touches sends at most three messages for that month, no matter how many documents are missing.
- A contact is an address at the client. Everyone marked primary is addressed on the same message, never one message each.
- A period is the calendar month, written as ISO 8601 year-month:
"2026-08".
One message per touch, listing everything currently missing. That batching rule is the whole reason the product does not make the inbox worse.
GET /v1/reminders/sending-today
Everything scheduled to leave during the firm's local day, grouped by client. This is the screen an accountant checks before they trust the engine with anything.
| Field | Type | Means |
|---|---|---|
localDay |
date | The firm's local calendar day |
timeZoneId |
string | The time zone that day was resolved in |
dayStartUtc |
date-time | That day's start, in UTC |
dayEndUtc |
date-time | That day's end, in UTC |
totalTouches |
integer | How many messages are scheduled in total |
profiles |
array | One entry per client with anything scheduled |
Each entry in profiles carries profileId, profileName, touchCount, and touches.
The day is the organization's local day, resolved the same way the daily send cap resolves it, so the preview and the cap can never disagree about which messages belong to today.
The touch shape
touches appears here, on a sequence, and in history. It is always this:
| Field | Type | Means |
|---|---|---|
id |
uuid | The touch |
touchNumber |
integer | Which touch of the sequence this is, starting at 1 |
scheduledFor |
date-time | When it goes out, or was meant to |
sentAt |
date-time, null | Null until it is handed to the mail channel |
outcome |
ReminderTouchOutcome |
See below |
cancelReason |
ReminderTouchCancelReason |
Why it was called off, null unless cancelled |
recipientContactId |
uuid | Which contact it is addressed to |
recipientEmail |
string or null | The address, on the sending-today preview only |
recipientName |
string or null | The name, on the sending-today preview only |
missingItemCount |
integer or null | How many items it lists |
isEscalation |
boolean | True for the final message to the escalation contact |
isManual |
boolean | True when somebody asked for it by hand |
outcome:
| Value | Name | Means |
|---|---|---|
0 |
Scheduled | Planned, not yet attempted |
1 |
Sent | Handed to the mail channel. Terminal |
2 |
Cancelled | Called off before going out, with a reason. Terminal |
3 |
Unknown | In flight when something crashed, not provably delivered. Terminal |
cancelReason:
| Value | Name | Means |
|---|---|---|
0 |
ListEmptied | Everything arrived between scheduling and sending |
1 |
StopConditionMet | A stop condition was true at send time |
2 |
WindowClosed | The send window had closed, and the touch was moved to the next one |
A cancelled touch is usually the good ending. The message content is computed at send time rather than at schedule time, exactly so a reminder for documents that already arrived gets cancelled instead of sent.
WindowClosed explains something that will otherwise look like a bug: two rows can carry the same
touchNumber. The abandoned attempt keeps its number and the replacement reuses it. Key on id,
not on touchNumber.
recipientEmail and recipientName are filled in on the sending-today preview and nowhere else.
Everywhere else a touch identifies its recipient by id only.
GET /v1/profiles/{profileId}/reminders
One client's chasing for one month.
| Parameter | Type | Means |
|---|---|---|
period |
string | The month as "2026-08". Omitted answers the most recent sequence |
Omitting period does not mean "this month". It means the sequence that is actually running, which
is what a firm looking at a client mid-close wants to see.
| Field | Type | Means |
|---|---|---|
profileId |
uuid | The client |
profileName |
string | Its name |
period |
string | The month this answer is about |
sequence |
object | The sequence, or null when there is not one |
escalationEnabled |
boolean | Whether the firm switched escalation on |
escalationConfirmed |
boolean | Whether the one-time confirmation was given |
escalationContactConfigured |
boolean | Whether this client has an escalation contact at all |
escalationRunnable |
boolean | Enabled and confirmed. The only flag that decides |
A null sequence is normal. It means nothing has been chased for that month, usually because
nothing was missing.
The sequence shape
| Field | Type | Means |
|---|---|---|
id |
uuid | The sequence |
profileId |
uuid | The client |
period |
string | The month |
state |
ReminderSequenceState |
See below |
pauseReason |
ReminderPauseReason |
Why it is held, null unless paused |
stopReason |
ReminderStopReason |
Why it was stopped, null unless stopped |
resumeAt |
date or null | When a pause lifts by itself |
maxTouches |
integer | The ceiling for this sequence |
touchesConsumed |
integer | How many are spent |
touchesRemaining |
integer | How many are left |
nextTouchAt |
date-time, null | When the next one goes, null when nothing is scheduled |
touches |
array | Every touch, in order |
outOfOfficeShiftBusinessDays |
integer | How much shift the sequence is allowed |
remainingOutOfOfficeShiftBusinessDays |
integer | How much of that is left |
foldsInPriorPeriods |
boolean | Whether older unfinished months are folded into this |
closeOutSentAt |
date-time, null | When the courtesy closing message went |
createdAt |
date-time | When chasing started |
endedAt |
date-time, null | When it ended |
lastUpdatedAt |
date-time, null | Last change |
state:
| Value | Name | Means |
|---|---|---|
0 |
Active | Chasing |
1 |
Paused | Held, with a reason. Not an ending |
2 |
Completed | Everything the month was missing arrived |
3 |
Exhausted | The touches are spent and documents are still missing |
4 |
Stopped | Stopped by something outside the missing list |
pauseReason:
| Value | Name | Means |
|---|---|---|
0 |
AccountantPaused | Somebody at the firm paused it |
1 |
ClientReplied | The client wrote back, so the engine stepped aside |
2 |
OutOfOfficeShift | The client is away past the point the sequence can wait |
stopReason:
| Value | Name | Means |
|---|---|---|
0 |
ProfileOffboarding | The client is leaving. There is no month-end to chase for |
1 |
OrganizationSuspended | The firm's account is not in good standing |
2 |
HardBounce | A previous message to that address bounced permanently |
3 exhausted is the state that needs a person. It means the engine spent every touch it was allowed
and documents are still missing, which is the whole point of having a maximum: at some point the
software stops and a human picks up the phone.
1 paused with reason 1, client replied, is the other one worth surfacing. When a client writes
back, chasing stops and the reply is forwarded to the firm. Syllevo does not argue with a client by
robot, so nothing resumes until somebody says so.
An ordinary out-of-office does not pause anything. If it states a return date the whole remaining
sequence shifts, which is what remainingOutOfOfficeShiftBusinessDays counts down. Pause reason 2
is for the far side of that allowance.
GET /v1/profiles/{profileId}/reminders/history
Every sequence this client has ever had, newest first, one summary each.
| Field | Type | Means |
|---|---|---|
profileId |
uuid | The client |
sequences |
array | One summary per month |
A summary carries id, period, state, pauseReason, stopReason, touchesConsumed,
closeOutSentAt, createdAt, endedAt, and lastTouch, which is one touch object rather than the
whole list. Fetch the month itself when you need the full set.
POST /v1/profiles/{profileId}/reminders/pause
Holds the chasing. SendReminders.
| Field | Type | Required | Means |
|---|---|---|---|
resumeAt |
date or null | no | Lift the pause automatically on this date |
Answers 200 with the sequence. Refused with 409 reminder_sequence_not_active when there is
nothing running to hold.
The pause is recorded as the firm's, not the client's. A client asking for quiet is a different reason with a different audit trail, and the engine writes that one itself.
POST /v1/profiles/{profileId}/reminders/resume
Starts it again. SendReminders. No body. Answers 200 with the sequence.
Refused with 409 reminder_sequence_not_paused when there is nothing to pick back up.
POST /v1/profiles/{profileId}/reminders/send-now
Asks for one message outside the schedule. SendReminders. No body.
This is the route that refuses most, and every refusal is the engine protecting the firm's relationship with its client. On success:
| Field | Type | Means |
|---|---|---|
sequenceId |
uuid | The sequence it belongs to |
verdict |
ManualTouchVerdict |
0 on success |
touchId |
uuid or null | The touch that was created |
touchNumber |
integer or null | Which touch it is |
scheduledFor |
date-time, null | When it actually goes out |
pendingTouchAt |
date-time, null | When the already-pending one goes |
Note the name: scheduled, not sent. Even a manual send lands in the next open moment of the recipient's send window. Asking at 22:00 does not write to a client at 22:00.
verdict:
| Value | Name | Means |
|---|---|---|
0 |
Scheduled | Placed at the next open moment |
1 |
SequenceNotChasing | The sequence is paused or ended |
2 |
NothingMissing | There is no list to send |
3 |
NoUsableContact | No address this can go to |
4 |
TouchAlreadyPending | One is already waiting |
Anything other than 0 comes back as 409 rather than 200, carrying the matching code:
reminder_sequence_not_chasing, reminder_nothing_missing, reminder_no_usable_contact, or
reminder_touch_already_pending. The last one carries pendingTouchAt alongside the usual error
fields, so a screen can say when the scheduled message goes instead of just refusing.
{
"status": 409,
"code": "reminder_touch_already_pending",
"messageKey": "web.errors.reminders.touchAlreadyPending",
"detail": null,
"details": null,
"errors": null,
"pendingTouchAt": "2026-09-12T09:00:00Z"
}
Settings
The firm sets one schedule. A client can override it. Both use the same body, and the effective values are what the engine reads.
GET and PUT /v1/reminders/settings
The firm's default, inherited by every client without an override. Admin on both, because this
changes how every client of the firm is chased.
| Field | Type | Default | Bounds | Means |
|---|---|---|---|---|
startOffsetBusinessDays |
integer | 5 | 0 to 20 | Business days after period end before the first message |
touchGapBusinessDays |
integer | 4 | 2 to 20 | Business days between messages |
escalationGapBusinessDays |
integer | 3 | 2 to 20 | Business days before the escalation message |
maxTouches |
integer | 3 | 1 to 5 | How many messages one month may ever cost a client |
dailySendCap |
integer | 300 | 1 to 5000 | Firm-wide ceiling on messages in one local day |
sendWindowStart |
time | 09:00 | Earliest a message may go | |
sendWindowEnd |
time | 17:00 | Latest a message may go | |
escalationEnabled |
boolean | false | Whether the final message to a third party may run | |
courtesyCloseOutEnabled |
boolean | true | Whether a closing thank-you goes when a month completes | |
confirmEscalation |
boolean | Request only. See below |
sendWindowEnd must be later than sendWindowStart and the window must be at least 60 minutes
wide. A window cannot wrap past midnight, and a one-minute window would be a send cap of zero in
practice.
maxTouches stops at 5 and there is no way to raise it. Five messages about one month is already
more than most clients will forgive.
Turning escalation on takes two saves. Escalation writes to somebody the client did not choose
to involve, a partner or the client's manager, so the first PUT with escalationEnabled: true is
refused with 409 reminder_escalation_confirmation_required. Send it again with
confirmEscalation: true and it sticks. After that, escalationConfirmed stays true and ordinary
saves do not ask again.
escalationRunnable is the flag that actually decides: enabled and confirmed together. Read that
one, not escalationEnabled.
The response adds isDefaulted, true when no row has been saved and the values above are the
built-in defaults, plus id, profileId, createdAt, lastUpdatedAt, escalationConfirmedAt,
and escalationRunnable.
GET, PUT, and DELETE /v1/profiles/{profileId}/reminders/settings
One client's override. ManageSettings on the client. The PUT body is the same as above.
| Field | Type | Means |
|---|---|---|
profileId |
uuid | The client |
hasOverride |
boolean | Whether this client has its own row |
override |
object | The override, null when there is not one |
effective |
object | What the engine will actually use |
effective carries the same schedule fields plus organizationSettingsId and profileSettingsId,
so you can tell which row each value came from.
Read effective. It is the answer to "what will happen to this client", and it is the only field
on this response that does not require you to work out inheritance yourself.
DELETE drops the override and puts the client back on the firm's default. It answers 200 with
the settings that now stand, so a screen does not have to ask again.
Contacts
Who at the client gets written to. SendReminders on the client for all three routes.
| Field | Type | Means |
|---|---|---|
id |
uuid | The contact |
profileId |
uuid | The client |
email |
string | The address |
displayName |
string or null | How they are greeted |
locale |
string | Which language their reminders are written in |
role |
ProfileContactRole |
0 primary, 1 escalation |
canReceiveReminders |
boolean | Whether anything will actually be sent here |
hasHardBounced |
boolean | Whether mail to this address bounced permanently |
hardBouncedAt |
date-time, null | When |
hardBouncedEmail |
string or null | The address that bounced, which may differ from the current one |
createdAt |
date-time | When it was added |
lastUpdatedAt |
date-time, null | Last change |
role:
0primary receives the sequence itself: one message per touch, listing everything currently missing. A client may have several, an owner and an office admin, and they are addressed together on one message rather than one message each.1escalation is written to only as the sequence's final touch, only where the firm configured one, and only in a separate message the client is never copied on. That message does not list the missing documents. Naming a client's outstanding items to a third party is a disclosure the client never agreed to.
hardBouncedEmail is kept separately from email on purpose. When somebody fixes a typo, the
current address changes and the record of what bounced stays, so the same mistake is visible rather
than quietly overwritten. canReceiveReminders is the field to branch on: a hard-bounced contact is
still listed, still editable, and still receives nothing.
GET /v1/profiles/{profileId}/contacts
Every contact this client has, hard-bounced ones included. Answers a plain array.
POST /v1/profiles/{profileId}/contacts
| Field | Type | Required | Means |
|---|---|---|---|
email |
string | yes | A valid address |
role |
ProfileContactRole |
yes | 0 primary or 1 escalation |
displayName |
string or null | no | How they are greeted |
locale |
string or null | no | Must be a locale Syllevo supports |
Answers 201. An unsupported locale is refused with 400 validation.unsupportedLocale rather
than quietly falling back to English, because an English reminder to a Romanian client is exactly
the failure that makes a firm stop trusting the engine.
POST /v1/profiles/22222222-2222-4222-8222-222222222222/contacts HTTP/1.1
Host: api.syllevo.com
X-Api-Key: syl_live_examplekeynotreal000000000000
Content-Type: application/json
{
"email": "maria@example.com",
"role": 0,
"displayName": "Maria Ionescu",
"locale": "ro"
}
PATCH /v1/profiles/{profileId}/contacts/{contactId}
| Field | Type | Required | Means |
|---|---|---|---|
email |
string | yes | The address, sent every time |
role |
ProfileContactRole |
no | Omit to leave it alone |
displayName |
string or null | no | Omit to leave it alone |
clearDisplayName |
boolean | no | Erase the name |
locale |
string or null | no | Omit to leave it alone |
email is required even on a patch. Sending clearDisplayName: true together with a
displayName is two contradictory instructions and is refused with 400 rather than guessed at.
There is no delete route. A contact that should stop receiving mail is handled by role and by bounce state, and the history of who was written to stays intact.