alf: the atproto Latency Fabric
alf.fly.dev/
1# ALF API Reference
2
3All endpoints require a valid ATProto Bearer token unless noted otherwise.
4
5XRPC endpoints follow the ATProto convention:
6- **Queries** (`type: query`) → `GET /xrpc/<method-id>?param=value`
7- **Procedures** (`type: procedure`) → `POST /xrpc/<method-id>` with `Content-Type: application/json`
8
9---
10
11## Write interface (proxied ATProto methods)
12
13These endpoints mirror the standard ATProto repo write API. Instead of writing to the PDS, ALF stores the record as a draft.
14
15### `com.atproto.repo.createRecord`
16
17Create a draft record. If `x-scheduled-at` is provided, the draft is immediately scheduled via a `once` recurrence schedule. If `x-trigger: webhook` is provided, a one-time secret URL is returned that publishes the draft on demand.
18
19**Request headers:**
20
21| Header | Required | Description |
22|--------|----------|-------------|
23| `Authorization` | Yes | `Bearer <access-token>` |
24| `x-scheduled-at` | No | ISO 8601 datetime. Creates a `once` schedule; draft gets a `scheduleId`. |
25| `x-trigger` | No | Set to `webhook` to generate a one-time trigger URL instead of a fixed schedule. |
26
27**Request body:**
28
29```jsonc
30{
31 "repo": "did:plc:alice", // Must match authenticated user
32 "collection": "app.bsky.feed.post",
33 "rkey": "3kw9mts3abc", // Optional; auto-generated TID if omitted
34 "record": { ... } // The ATProto record
35}
36```
37
38**Response (200):**
39
40```jsonc
41{
42 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc",
43 "cid": "bafyreib...",
44 "validationStatus": "unknown",
45 "triggerUrl": "https://alf.example.com/triggers/..." // Only when x-trigger: webhook
46}
47```
48
49**Errors:**
50
51| Code | Description |
52|------|-------------|
53| `InvalidRequest` (400) | Missing `collection` or `record`; `repo` doesn't match authenticated user |
54| `DuplicateDraft` (400) | A draft with this URI already exists and is not cancelled/published |
55| `AuthRequired` (401) | Missing or invalid Bearer token |
56
57---
58
59### `com.atproto.repo.putRecord`
60
61Create a draft for a `putRecord` (create-or-update) operation.
62
63**Request headers:** Same as `createRecord` (including `x-scheduled-at` and `x-trigger`).
64
65**Request body:**
66
67```jsonc
68{
69 "repo": "did:plc:alice",
70 "collection": "app.bsky.actor.profile",
71 "rkey": "self",
72 "record": { ... }
73}
74```
75
76**Response (200):** Same shape as `createRecord`.
77
78---
79
80### `com.atproto.repo.deleteRecord`
81
82Create a draft for a `deleteRecord` operation. No record content is needed.
83
84**Request headers:** Same as `createRecord` (including `x-scheduled-at` and `x-trigger`).
85
86**Request body:**
87
88```jsonc
89{
90 "repo": "did:plc:alice",
91 "collection": "app.bsky.feed.post",
92 "rkey": "3kw9mts3abc"
93}
94```
95
96**Response (200):**
97
98```jsonc
99{}
100```
101
102---
103
104## Draft management methods
105
106### `town.roundabout.scheduledPosts.listPosts`
107
108List drafts for a user. Users can only list their own drafts.
109
110**Query parameters:**
111
112| Parameter | Required | Description |
113|-----------|----------|-------------|
114| `repo` | Yes | DID of the user. Must match the authenticated user. |
115| `status` | No | Filter by status: `draft`, `scheduled`, `publishing`, `published`, `failed`, `cancelled` |
116| `limit` | No | Number of results (1–100, default 50) |
117| `cursor` | No | Pagination cursor from a previous response |
118
119**Response (200):**
120
121```jsonc
122{
123 "posts": [
124 { /* DraftView */ },
125 { /* DraftView */ }
126 ],
127 "cursor": "..." // Present if more results exist
128}
129```
130
131---
132
133### `town.roundabout.scheduledPosts.getPost`
134
135Get a single draft by AT-URI.
136
137**Query parameters:**
138
139| Parameter | Required | Description |
140|-----------|----------|-------------|
141| `uri` | Yes | AT-URI of the draft |
142
143**Response (200):** A `DraftView` object.
144
145**Errors:**
146
147| Code | Description |
148|------|-------------|
149| `NotFound` (400) | No draft with this URI |
150| `AuthRequired` (401) | URI belongs to a different user |
151
152---
153
154### `town.roundabout.scheduledPosts.schedulePost`
155
156Set or change the publish time for a draft. The draft must be in `draft` or `scheduled` status.
157
158**Request body:**
159
160```jsonc
161{
162 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc",
163 "publishAt": "2025-06-01T09:00:00.000Z" // ISO 8601
164}
165```
166
167**Response (200):** The updated `DraftView`.
168
169**Errors:**
170
171| Code | Description |
172|------|-------------|
173| `InvalidRequest` (400) | `publishAt` is not a valid datetime |
174| `NotFound` (400) | Draft not found or not in a schedulable state (e.g., already published) |
175| `AuthRequired` (401) | Draft belongs to a different user |
176
177---
178
179### `town.roundabout.scheduledPosts.publishPost`
180
181Immediately publish a draft to the user's PDS. This is a synchronous operation — the response reflects the final published state.
182
183**Request body:**
184
185```jsonc
186{
187 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc"
188}
189```
190
191**Response (200):** The updated `DraftView` (status will be `published` on success, or `failed` if the PDS write failed).
192
193**Errors:**
194
195| Code | Description |
196|------|-------------|
197| `NotFound` (400) | Draft not found |
198| `AuthRequired` (401) | Draft belongs to a different user |
199
200---
201
202### `town.roundabout.scheduledPosts.updatePost`
203
204Update the record content and/or schedule of a draft. Both `record` and `scheduledAt` are optional — only the fields provided are updated. The CID is recomputed if `record` changes.
205
206The draft must be in `draft` or `scheduled` status.
207
208**Request body:**
209
210```jsonc
211{
212 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc",
213 "record": { ... }, // Optional: new record content
214 "scheduledAt": "2025-06-01T09:00:00.000Z" // Optional: new publish time
215}
216```
217
218**Response (200):** The updated `DraftView`.
219
220**Errors:**
221
222| Code | Description |
223|------|-------------|
224| `InvalidRequest` (400) | `scheduledAt` is not a valid datetime |
225| `NotFound` (400) | Draft not found or not in an updatable state |
226| `AuthRequired` (401) | Draft belongs to a different user |
227
228---
229
230### `town.roundabout.scheduledPosts.deletePost`
231
232Cancel and discard a draft. Sets status to `cancelled`. This is permanent.
233
234**Request body:**
235
236```jsonc
237{
238 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc"
239}
240```
241
242**Response (200):**
243
244```jsonc
245{}
246```
247
248**Errors:**
249
250| Code | Description |
251|------|-------------|
252| `AuthRequired` (401) | Draft belongs to a different user |
253
254---
255
256## Schedule management methods
257
258Recurring schedules fire on a recurrence rule and automatically create a new draft for each occurrence. The draft is published at the scheduled time, then the next draft is queued.
259
260### `town.roundabout.scheduledPosts.createSchedule`
261
262Create a recurring schedule. ALF computes the first occurrence immediately and creates a draft for it.
263
264**Request body:**
265
266```jsonc
267{
268 "collection": "app.bsky.feed.post",
269 "recurrenceRule": { /* RecurrenceRule */ },
270 "timezone": "America/New_York",
271 "record": { ... }, // Static post content (mutually exclusive with contentUrl)
272 "contentUrl": "https://..." // Dynamic content URL (mutually exclusive with record)
273}
274```
275
276**Response (200):**
277
278```jsonc
279{
280 "schedule": { /* ScheduleView */ }
281}
282```
283
284**Errors:**
285
286| Code | Description |
287|------|-------------|
288| `InvalidRequest` (400) | `record` and `contentUrl` both provided; rule produces no future occurrences |
289| `AuthRequired` (401) | Missing or invalid Bearer token |
290
291---
292
293### `town.roundabout.scheduledPosts.listSchedules`
294
295List schedules for a user.
296
297**Query parameters:**
298
299| Parameter | Required | Description |
300|-----------|----------|-------------|
301| `repo` | Yes | DID of the user. Must match the authenticated user. |
302| `status` | No | Filter by status: `active`, `paused`, `cancelled`, `completed`, `error` |
303| `limit` | No | Number of results (1–100, default 50) |
304| `cursor` | No | Pagination cursor from a previous response |
305
306**Response (200):**
307
308```jsonc
309{
310 "schedules": [ { /* ScheduleView */ } ],
311 "cursor": "..."
312}
313```
314
315---
316
317### `town.roundabout.scheduledPosts.getSchedule`
318
319Get a single schedule by ID.
320
321**Query parameters:**
322
323| Parameter | Required | Description |
324|-----------|----------|-------------|
325| `id` | Yes | Schedule UUID |
326
327**Response (200):** A `ScheduleView` object.
328
329**Errors:**
330
331| Code | Description |
332|------|-------------|
333| `NotFound` (400) | No schedule with this ID |
334| `AuthRequired` (401) | Schedule belongs to a different user |
335
336---
337
338### `town.roundabout.scheduledPosts.updateSchedule`
339
340Pause or resume a schedule. Pausing cancels the pending next draft; resuming immediately computes and queues the next occurrence.
341
342**Request body:**
343
344```jsonc
345{
346 "id": "550e8400-e29b-41d4-a716-446655440000",
347 "status": "paused" // "paused" or "active"
348}
349```
350
351**Response (200):** The updated `ScheduleView`.
352
353**Errors:**
354
355| Code | Description |
356|------|-------------|
357| `NotFound` (400) | Schedule not found |
358| `AuthRequired` (401) | Schedule belongs to a different user |
359
360---
361
362### `town.roundabout.scheduledPosts.deleteSchedule`
363
364Delete a schedule and cancel its pending draft. This is permanent.
365
366**Request body:**
367
368```jsonc
369{
370 "id": "550e8400-e29b-41d4-a716-446655440000"
371}
372```
373
374**Response (200):**
375
376```jsonc
377{}
378```
379
380**Errors:**
381
382| Code | Description |
383|------|-------------|
384| `NotFound` (400) | Schedule not found |
385| `AuthRequired` (401) | Schedule belongs to a different user |
386
387---
388
389## REST endpoints
390
391### `POST /blob`
392
393Upload a blob (image) for use in a scheduled post. ALF stores the raw bytes and returns the CID. Use the CID when constructing the record's blob references.
394
395The blob is stored until the draft is published, at which point it is re-uploaded to the user's PDS.
396
397**Request headers:**
398
399| Header | Required | Description |
400|--------|----------|-------------|
401| `Authorization` | Yes | `Bearer <access-token>` |
402| `Content-Type` | Yes | MIME type of the blob (e.g., `image/jpeg`, `image/png`) |
403
404**Request body:** Raw image bytes (max 10MB).
405
406**Response (200):**
407
408```jsonc
409{
410 "cid": "bafkreihdwdcefgh...",
411 "mimeType": "image/jpeg",
412 "size": 204800
413}
414```
415
416Use `cid` in blob references in your record:
417
418```jsonc
419{
420 "$type": "blob",
421 "ref": { "$link": "bafkreihdwdcefgh..." },
422 "mimeType": "image/jpeg",
423 "size": 204800
424}
425```
426
427**Errors:**
428
429| Code | Description |
430|------|-------------|
431| `InvalidRequest` (400) | Empty request body |
432| `AuthRequired` (401) | Missing or invalid Bearer token |
433
434---
435
436### `POST /triggers/:key`
437
438Fire a webhook trigger draft immediately. No authentication required — the URL itself is the secret.
439
440**Response (200):**
441
442```jsonc
443{
444 "published": true,
445 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc"
446}
447```
448
449**Errors:**
450
451| Code | Description |
452|------|-------------|
453| `NotFound` (404) | Trigger key not found |
454| `TriggerAlreadyFired` (409) | Draft already published, failed, or cancelled |
455
456---
457
458### `GET /oauth/status`
459
460Check whether the authenticated user has authorized ALF to publish on their behalf.
461
462**Request headers:**
463
464| Header | Required | Description |
465|--------|----------|-------------|
466| `Authorization` | Yes | `Bearer <access-token>` |
467
468**Response (200):**
469
470```jsonc
471{
472 "authorized": true,
473 "authType": "oauth" // "oauth" | null
474}
475```
476
477If the token is invalid or the user has not authorized ALF, returns `{ "authorized": false, "authType": null }`.
478
479---
480
481### `GET /oauth/authorize`
482
483Initiate the OAuth authorization flow for a user.
484
485**Query parameters:**
486
487| Parameter | Required | Description |
488|-----------|----------|-------------|
489| `handle` | Yes | ATProto handle (e.g., `alice.bsky.social`) or DID |
490
491Redirects the user to their PDS for authorization. After approval, the PDS redirects back to `/oauth/callback`.
492
493---
494
495### `GET /health`
496
497Basic health check. No authentication required.
498
499**Response (200):**
500
501```jsonc
502{ "status": "ok", "service": "alf" }
503```
504
505---
506
507## DraftView object
508
509All draft management endpoints return a `DraftView`:
510
511```typescript
512{
513 uri: string; // AT-URI: "at://did:plc:.../collection/rkey"
514 cid?: string; // Pre-computed DAG-CBOR CID; absent for deleteRecord drafts
515 collection: string; // NSID, e.g. "app.bsky.feed.post"
516 rkey: string; // Record key
517 action: "create" | "put" | "delete";
518 status: "draft" | "scheduled" | "publishing" | "published" | "failed" | "cancelled";
519 scheduledAt?: string; // ISO 8601 datetime; absent if unscheduled
520 createdAt: string; // ISO 8601 datetime
521 failureReason?: string; // Present only when status is "failed"
522 record?: object; // Record content; absent for deleteRecord drafts
523 scheduleId?: string; // UUID of the parent schedule, if this draft was created by one
524 triggerUrl?: string; // One-time webhook URL; only present on drafts with x-trigger: webhook
525}
526```
527
528### Draft statuses
529
530| Status | Description |
531|--------|-------------|
532| `draft` | Saved but not scheduled. Will not be published automatically. |
533| `scheduled` | Has a publish time. Will be published by the scheduler. |
534| `publishing` | Currently being published (claimed by scheduler). |
535| `published` | Successfully written to the PDS. |
536| `failed` | Failed to publish after all retry attempts. |
537| `cancelled` | Cancelled by the user via `deletePost`. |
538
539---
540
541## ScheduleView object
542
543Schedule management endpoints return a `ScheduleView`:
544
545```typescript
546{
547 id: string; // UUID
548 collection: string; // NSID, e.g. "app.bsky.feed.post"
549 status: "active" | "paused" | "cancelled" | "completed" | "error";
550 recurrenceRule: RecurrenceRule; // Full rule object (see below)
551 timezone: string; // IANA timezone
552 fireCount: number; // Number of times this schedule has fired
553 createdAt: string; // ISO 8601
554 lastFiredAt?: string; // ISO 8601; present after first firing
555 nextDraftUri?: string; // AT-URI of the pending next draft
556 record?: object; // Static post content, if applicable
557 contentUrl?: string; // Dynamic content URL, if applicable
558}
559```
560
561### Schedule statuses
562
563| Status | Description |
564|--------|-------------|
565| `active` | Running normally; a pending draft exists. |
566| `paused` | Paused by the user; no pending draft. |
567| `cancelled` | Deleted by the user. |
568| `completed` | Series naturally exhausted (e.g., a `once` schedule that has fired). |
569| `error` | An unrecoverable error occurred during chaining or publishing. |
570
571---
572
573## RecurrenceRule object
574
575A `RecurrenceRule` is a JSON object passed to `createSchedule`. It contains a core rule plus optional bounds and exception lists.
576
577```typescript
578{
579 rule: RecurrenceRuleCore; // The core firing pattern (see below)
580 startDate?: string; // YYYY-MM-DD: first occurrence must be on or after this date
581 endDate?: string; // YYYY-MM-DD: no occurrences after this date
582 count?: number; // Maximum number of total firings
583 revisions?: RecurrenceRevision[]; // Time-spec changes taking effect from a given date
584 exceptions?: RecurrenceException[]; // Per-occurrence overrides (cancel, move, override_time, override_payload)
585}
586```
587
588### Core rule types
589
590| Type | Description | Extra fields |
591|------|-------------|--------------|
592| `once` | Fires exactly once at the given UTC datetime. | `datetime: string` (ISO 8601 UTC) |
593| `daily` | Every N days at the given time. | `interval?: number`, `time: TimeSpec` |
594| `weekly` | Every N weeks on the specified days of the week. | `interval?: number`, `daysOfWeek: number[]` (0=Sun–6=Sat), `time: TimeSpec` |
595| `monthly_on_day` | Nth day of every N-th month; clamps to last day if month is shorter. | `interval?: number`, `dayOfMonth: number` (1–31), `time: TimeSpec` |
596| `monthly_nth_weekday` | Nth weekday of every N-th month (nth=-1 means last). | `interval?: number`, `nth: number` (1–4 or -1), `weekday: number` (0=Sun–6=Sat), `time: TimeSpec` |
597| `monthly_last_business_day` | Last Mon–Fri of every N-th month. | `interval?: number`, `time: TimeSpec` |
598| `yearly_on_month_day` | Specific month and day each year; clamps Feb 29 in non-leap years. | `interval?: number`, `month: number` (1–12), `dayOfMonth: number` (1–31), `time: TimeSpec` |
599| `yearly_nth_weekday` | Nth weekday of a specific month each year. | `interval?: number`, `month: number`, `nth: number`, `weekday: number`, `time: TimeSpec` |
600| `quarterly_last_weekday` | Last occurrence of a weekday in each quarter-end month (Mar, Jun, Sep, Dec). | `interval?: number` (quarters between fires, default 1), `weekday: number`, `time: TimeSpec` |
601
602### TimeSpec
603
604All repeating rule types include a `time` field that is one of:
605
606```typescript
607// Wall-clock time in a named timezone (DST-aware)
608{ type: "wall_time", hour: number, minute: number, second?: number, timezone: string }
609
610// Fixed UTC offset (does not adjust for DST)
611{ type: "fixed_instant", utcOffsetMinutes: number, hour: number, minute: number, second?: number }
612```
613
614### Exception types
615
616Exceptions are matched by their `date` field (a `YYYY-MM-DD` string in the rule's timezone). Multiple exceptions for different dates can coexist.
617
618| Type | Effect | Fields |
619|------|--------|--------|
620| `cancel` | Skip this occurrence entirely. | `date: string` |
621| `move` | Publish at a different UTC datetime instead. | `date: string`, `newDatetime: string` (ISO 8601 UTC) |
622| `override_time` | Use a different time spec for this occurrence only. | `date: string`, `time: TimeSpec` |
623| `override_payload` | Publish a different record for this occurrence. Resolved at publish time, not schedule time. | `date: string`, `record: object` |
624
625### Example: daily post at 9 AM ET, skipping a holiday
626
627```jsonc
628{
629 "rule": {
630 "type": "daily",
631 "time": { "type": "wall_time", "hour": 9, "minute": 0, "timezone": "America/New_York" }
632 },
633 "exceptions": [
634 { "type": "cancel", "date": "2025-07-04" },
635 { "type": "override_payload", "date": "2025-12-25", "record": {
636 "$type": "app.bsky.feed.post", "text": "Happy holidays!", "createdAt": "2025-12-25T00:00:00Z"
637 }}
638 ]
639}
640```
641
642### Example: once schedule
643
644```jsonc
645{
646 "rule": { "type": "once", "datetime": "2025-06-01T14:00:00Z" }
647}
648```
649
650> **Note:** Using `x-scheduled-at` on `createRecord` or `putRecord` automatically creates a `once` schedule behind the scenes. The returned draft will have a `scheduleId` pointing to it.
651
652### Dynamic content schedules
653
654If `contentUrl` is provided instead of `record`, ALF fetches the URL at publish time with two query parameters:
655
656| Parameter | Description |
657|-----------|-------------|
658| `fireCount` | 1-based count of how many times this schedule has fired (including this firing) |
659| `scheduledAt` | ISO 8601 datetime of the scheduled occurrence |
660
661The response must be a JSON object that is the record to publish.
662
663---
664
665## Client utilities (`@newpublic/recurrence`)
666
667The recurrence package exported by the monorepo includes two utilities for working with `RecurrenceRule` objects on the client side.
668
669### `parseRecurrenceRule(input, defaultTimezone?)`
670
671Parses a plain-English string into a `RecurrenceRule`. Returns `null` if the input doesn't match any known pattern.
672
673```typescript
674import { parseRecurrenceRule } from '@newpublic/recurrence';
675
676parseRecurrenceRule('every Monday and Friday at 9am ET');
677parseRecurrenceRule('last business day of each month at 5pm UTC');
678parseRecurrenceRule('every year on January 1st at midnight', 'America/New_York');
679```
680
681`defaultTimezone` is an IANA timezone used when no timezone is detected in the input; defaults to `'UTC'`. See the [package README](../packages/recurrence/README.md) for the full list of supported phrasings.
682
683### `formatRecurrenceRule(rule)`
684
685Formats a `RecurrenceRule` as a human-readable English string. Useful for displaying schedule summaries in UI.
686
687```typescript
688import { formatRecurrenceRule } from '@newpublic/recurrence';
689
690formatRecurrenceRule(rule);
691// e.g. "every Monday and Friday at 9am (ET)"
692// e.g. "last business day of every month at 5pm (UTC)"
693// e.g. "every year on January 1st at midnight (UTC), starting 2026-01-01"
694```