Sending activities
In ActivityPub, an actor can deliver an activity to another actor by sending an HTTP POST request to the recipient's inbox. Fedify provides an abstracted way to send activities to other actors' inboxes.
Prerequisite: actor key pairs
Before sending an activity to another actor, you need to have the sender's key pairs. The key pairs are used to sign the activity so that the recipient can verify the sender's identity. The key pairs can be registered by calling setKeyPairsDispatcher() method.
For more information about this topic, see Public keys of an Actor section in the Actor dispatcher section.
Sending an activity
To send an activity to another actor, you can use the Context.sendActivity() method. The following shows how to send a Follow activity to another actor:
import { type Context } from "@fedify/fedify";
import { Follow, type Recipient } from "@fedify/vocab";
async function sendFollow(
ctx: Context<void>,
senderId: string,
recipient: Recipient,
) {
await ctx.sendActivity(
{ identifier: senderId },
recipient,
new Follow({
id: new URL(`https://example.com/${senderId}/follows/${recipient.id}`),
actor: ctx.getActorUri(senderId),
object: recipient.id,
}),
);
}TIP
Wonder where you can acquire a Context object? See the Where to get a Context object section in the Context section.
Specifying a sender
The first argument of the Context.sendActivity() method is the sender of the activity. It can be three types of values:
{ identifier: string }
If you specify an object with the identifier property, the sender is the actor with the given identifier. The identifier is used to find the actor's key pairs to sign the activity:
await ctx.sendActivity(
{ identifier: "2bd304f9-36b3-44f0-bf0b-29124aafcbb4" },
"followers",
activity,
);{ username: string }
If you specify an object with the username property, the sender is the actor with the given WebFinger username. The username is used to find the actor's key pairs to sign the activity:
await ctx.sendActivity(
{ username: "john" },
"followers",
activity,
);If you don't decouple the username from the identifier, this is the same as the { identifier: string } case.
SenderKeyPair | SenderKeyPair[]
If you specify a SenderKeyPair object or an array of SenderKeyPair objects, the sender is the set of the given key pairs:
await ctx.sendActivity(
await ctx.getActorKeyPairs("2bd304f9-36b3-44f0-bf0b-29124aafcbb4"),
recipients, // You need to specify the recipients manually
activity,
);However, you probably don't want to use this option directly; instead, you should use above two options to specify the sender.
Specifying recipients
The second argument of the Context.sendActivity() method is the recipients of the activity. It can be multiple, and you can set them to Recipient objects, or Actor objects (which satisfy the Recipient interface), or simply "followers" string which is a special value that represents the followers of the sender.
Actor | Actor[]
If you specify Actor objects, the activity is delivered to the actors' inboxes (or shared inboxes if some actors support it and you turn on the preferSharedInbox option):
await ctx.sendActivity(
{ identifier: "2bd304f9-36b3-44f0-bf0b-29124aafcbb4" },
actor,
activity,
);Or you can specify multiple actors:
await ctx.sendActivity(
{ username: "john" },
[actor, actor2, actor3],
activity,
);Recipient | Recipient[]
If you specify any objects that satisfy the Recipient interface, the activity is delivered to the recipients' inboxes (or shared inboxes if some recipients support it and you turn on the preferSharedInbox option).
The Recipient interface is defined as follows:
export interface Recipient {
readonly id: URL | null;
readonly inboxId: URL | null;
readonly endpoints?: {
sharedInbox: URL | null;
} | null;
readonly gateways?: readonly URL[];
}Here's an example of specifying a Recipient object:
await ctx.sendActivity(
{ identifier: "2bd304f9-36b3-44f0-bf0b-29124aafcbb4" },
{
id: new URL("https://example.com/actors/1"),
inboxId: new URL("https://example.com/actors/1/inbox"),
} satisfies Recipient,
activity,
);Or you can provide its shared inbox endpoint as well:
await ctx.sendActivity(
{ username: "john" },
{
id: new URL("https://example.com/actors/1"),
inboxId: new URL("https://example.com/actors/1/inbox"),
endpoints: {
sharedInbox: new URL("https://example.com/inbox"),
}
} satisfies Recipient,
activity,
{ preferSharedInbox: true },
);"followers"
NOTE
You need to implement the followers collection dispatcher to use this feature.
It's the most convenient way to deliver an activity to the followers of the sender.
If you specify the "followers" string, the activity is delivered to the followers of the sender. It is a special value that represents the followers of the sender:
await ctx.sendActivity(
{ identifier: "2bd304f9-36b3-44f0-bf0b-29124aafcbb4" },
"followers",
activity,
);NOTE
You cannot use the "followers" string if the sender is a SenderKeyPair or an array of SenderKeyPair objects. You need to specify the recipients manually in this case.
TIP
Does the Context.sendActivity() method take quite a long time to complete even if you configured the queue? It might be because the followers collection is large and the method under the hood invokes your followers collection dispatcher multiple times to paginate the collection. To improve the performance, you should implement the one-short followers collection for gathering recipients.
Specifying an activity
The third argument of the Context.sendActivity() method is the activity to send. It can be an instance of any subclass of the Activity class, such as Follow, Create, Like, and so on.
Every activity must have the actor property, which corresponds to the sender of the activity. You can get the actor's URI by calling the Context.getActorUri() method.
Every activity should have the id property, which is a unique IRI for the activity. If you don't specify the id property, Fedify automatically generates a unique IRI for the activity by default—but it is recommended to specify the id property explicitly.
TIP
The activity's id does not have to be necessarily dereferenceable—it's totally fine to use a URL with a fragment identifier, such as https://example.com/123#follow/751d477f-2167-4473-ace5-4404f4760c0d.
CAUTION
Sometimes you may tempted to derive the activity's id from the actor and the object's id properties, but keep in mind that the same kind of activities can be made by the same actor to the same object multiple times.
For example, if Alice sends a Follow activity to Bob, Bob accepts it, Alice sends an Undo(Follow) activity to Bob, and Alice sends a Follow activity to Bob again, the id of the Follow activity should be different from the previous one, even if the actor and the object are the same.
If they have the same id, the recipient may mistakenly think that the activity is a duplicate of the previous one and ignore it, which is not what you want.
To ensure the uniqueness of the activity's id, it is recommended to contain a UUID or a similar unique identifier in the id property.
The most of cases, an activity should have the to property, which corresponds to the recipient of the activity. If it's a public activity, you can set the to property to the PUBLIC_COLLECTION constant, which represents the public addressing. There's also the cc property, which corresponds to the secondary recipients of the activity. Those two properties can be multiple, and you can set them to Actor objects, Collection objects, or URL objects.
TIP
If you want to mimic the behavior of Mastodon's post privacy settings, here's a table that shows how to set the to and cc properties:
| Privacy setting | to property | cc property |
|---|---|---|
| Public | PUBLIC_COLLECTION | Context.getFollowersUri() |
| Quiet public[1] | Context.getFollowersUri() | PUBLIC_COLLECTION |
| Followers-only | Context.getFollowersUri() | Mentioned actors |
| Direct message | Mentioned actors |
To wrap up, the following is an example of sending a Create activity:
await ctx.sendActivity(
{ identifier: senderId },
recipients,
new Create({
id: new URL(`#create`, ctx.getObjectUri(Note, { id: noteId })),
actor: ctx.getActorUri(senderId),
object: new Note({
id: ctx.getObjectUri(Note, { id: noteId }),
attribution: ctx.getActorUri(senderId),
to: PUBLIC_COLLECTION,
cc: ctx.getFollowersUri(senderId),
content,
}),
}),
);Enqueuing an outgoing activity
The delivery failure can happen for various reasons, such as network failure, recipient server failure, and so on. For reliable delivery, Fedify enqueues an outgoing activity to the queue instead of immediately sending it to the recipient's inbox if possible; the system retries the delivery on failure.
This queueing mechanism is enabled only if a queue option is set to the createFederation() function:
import { createFederation, InProcessMessageQueue } from "@fedify/fedify";
const federation = createFederation({
// Omitted for brevity; see the related section for details.
queue: new InProcessMessageQueue(),
});NOTE
The InProcessMessageQueue is a simple in-memory message queue that is suitable for development and testing. For production use, you should consider using a more robust message queue, such as RedisMessageQueue from the @fedify/redis package or MysqlMessageQueue from the @fedify/mysql package. For existing Deno applications, DenoKvMessageQueue from @fedify/denokv is also available, but it is deprecated since Fedify 2.5.0 and will be removed in Fedify 3.0.0.
For further information, see the Message queue section.
The failed activities are automatically retried after a certain period of time. By default, Fedify handles retries using exponential backoff with a maximum of 10 retries, but you can customize it by providing an outboxRetryPolicy option to the createFederation() function.
However, if your message queue backend provides native retry mechanisms (indicated by MessageQueue.nativeRetrial being true), Fedify will skip its own retry logic and rely on the backend to handle retries. This avoids duplicate retry mechanisms and leverages the backend's optimized retry features.
If the queue is not set, the sendActivity() method immediately sends the activity to the recipient's inbox. If the delivery fails, it throws an error and does not retry the delivery.
Observing outgoing activities
This API is available since Fedify 2.5.0.
Register callbacks with Federatable.setOutboundObservers() to record outgoing sends, original-payload forwarding, and automatic portable-inbox replication. The method is available on federation builders and directly created federations. Each callback receives the current Context and an event or report, and is awaited at its observation boundary:
federation.setOutboundObservers({
async onBroadcast(ctx, event) {
await recordBroadcast(ctx.data.requestId, event);
if (event.stage === "preparation-finished" && event.result.type === "failed") {
console.error(event.result.failure.phase, event.result.failure.error);
}
},
async onDelivery(ctx, event) {
await recordDelivery(ctx.data.requestId, event);
if (event.stage === "handoff" && event.result.type === "enqueue-failed") {
console.error("Queue acceptance is unknown", event.result.error);
}
},
});recordBroadcast() and recordDelivery() represent your application's recording functions. Queued work uses the current worker's context and data. The original HTTP source, when known, is recorded separately in provenance. Observation does not load additional data or serialize an activity again. Routing and source metadata are immutable snapshots; the context, application data, Activity objects, and original exceptions are not deep-copied.
Broadcast events include an immutable activity reference with optional id and type strings; type is the Activity class's type IRI. The reference contains identity facts, not a copy of the payload. The started event can have an empty reference, and later events can contain more information. After successful transformation, both fields are refreshed from the resulting Activity, including for skipped or early-failed sends. Unavailable facts are omitted rather than copied from the old Activity. Reading the class type for observation cannot replace a send error. Observation does not parse payloads or load contexts to fill the reference. Activity IDs can repeat across broadcasts; use broadcastId for invocation identity and dispatchId for application correlation. A reported Activity ID does not establish authentication.
onBroadcast reports one send, forward, or automatic portable-replication invocation. Its started event runs before preparation; preparation-finished describes whether the producer prepared work for delivery or fanout, skipped sending, or failed. Fanout-ready work still needs worker-side delivery preparation. A prepared result does not confirm queue acceptance or HTTP delivery, and later send failures do not rewrite it. Skips such as no-destinations or forwarding-disabled describe existing decisions; an unsigned skip reflects the forwarding signature/proof shape check, not authentication.
onDelivery reports each deduplicated destination or claimed replication target. Different inboxes on one host remain separate deliveries. A planned event identifies the destination; a handoff records direct sender entry or the result of enqueueing work. An enqueued result confirms producer acceptance. An enqueue-failed result leaves backend acceptance unknown, because the backend may have accepted work before rejecting the call. Destination-local preparation failures are reported without inventing an attempt. Earlier handoffs remain valid if preparation for a later destination fails.
onAttempt reports the result when a concrete send settles. Each sender entry gets a fresh attemptId; retries and alternative gateways create new attempts. onHttpExchange reports each actual fetch within an attempt, including signed requests, redirects, and signature challenges. The pair of attemptId and exchangeIndex identifies an exchange. A fetch that receives non-2xx headers still has a response outcome; a rejected fetch has a failed outcome.
federation.setOutboundObservers({
onHttpExchange(_ctx, report) {
console.log(report.attemptId, report.exchangeIndex, report.request.url);
if (report.outcome.type === "response") {
console.log("HTTP response", report.outcome.response.status);
}
},
onAttempt(_ctx, report) {
console.log(report.attemptId, report.httpExchangeCount);
const response = report.outcome.response;
if (response.type === "final") {
console.log("Final response", response.exchangeIndex, response.metadata.status);
}
if (report.outcome.type === "failed") {
console.error(report.outcome.failure.kind, report.outcome.failure.error);
}
},
});An attempt can fail during URL validation or signing before any fetch, leaving httpExchangeCount at zero. Failures before sender entry, such as queue key import or fanout parsing, and worker holds or drops create no attempt.
An exchange response and an attempt's final response are different. A 401 challenge followed by a successful retry produces two exchanges; the later response is final. If signing the retry fails, the 401 remains intermediate. outcome.response distinguishes final, intermediate-only, not-observed, and undetermined evidence. A final response is retained even when later cache or cleanup work fails. For a final non-OK response, HTTP failure remains primary and additional local errors appear in diagnostics; a local failure after a final 2xx still makes the attempt fail. outcome.error retains the value that escaped the sender, while failure.error retains operation evidence when available. Neither queue acceptance nor remote HTTP acceptance proves that the remote application applied the activity.
Worker execution and actions
onWorker receives one OutboundWorkerReport when a fanout or outbox worker's local execution and instrumentation settle. attemptsStarted counts concrete sender entries, including fallback gateways; enqueueing a child task does not increase it. Outbox reports identify one delivery, while fanout reports carry delivery references on their child actions.
federation.setOutboundObservers({
onWorker(_ctx, report) {
console.log(report.role, report.result.type, report.attemptsStarted);
for (const action of report.actions) {
if (action.type === "scheduled") {
console.log(action.action, action.queue.queueMessageId, action.inboxUrls);
if (action.action !== "enqueue") console.log(action.delay);
}
}
},
});Read actions separately from result. A completed result means the worker returned normally, including handled failures or abandonment. Its actions record accepted enqueues, retries and deferrals, direct handoffs, abandoned routes, and failed scheduling or drop operations. Retry and deferral actions include their actual delay and any per-route constraints. One gateway can be abandoned while another continues. An empty action list means none was observed by settlement.
report.preparationFailures retains caught preparation failures with their inboxUrl, phase, and original error, even if another gateway later succeeds or native retry handling rethrows a later error without scheduling an action. When a failure contributes to a retry, deferral, or abandonment, the action's preparationFailures also identifies that cause. The worker can still be completed after handling a failure. If enqueueing fails, the action's error describes the enqueue failure separately.
Accepted actions survive later failures; a rejected batch leaves every submitted child's acceptance unknown. Earlier direct attempts can continue after a later preparation failure settles the worker. A local result does not establish backend acknowledgment or future native redelivery. See observing outgoing workers.
Identities across queues
broadcastId groups an invocation and its continuations. deliveryId identifies one destination obligation and survives fanout replay, retries, holds, and route changes. Alternative portable gateways share one delivery; automatic replication creates independent deliveries. Each concrete attempt gets a new attemptId. Queue task IDs and queueAttempt describe scheduling, not the number of send executions. Native redelivery can reuse both while creating a fresh attempt.
Identity allocation and propagation follow these rules:
| Field | Allocation or source | Propagation and replacement |
|---|---|---|
inboxRequestId | Fresh server-generated ID for each routed HTTP inbox request, before preparation. | Preserved through inbox retries, derived contexts, outgoing sends, fanout, outbox retries, and replication. Repeated deliveries of the same Activity have different IDs. |
Original logging requestId | The existing logging label selected at HTTP ingress; externally supplied values are informational and nonunique. | Preserved as source metadata through queues. It does not replace an unrelated worker's current logging label. |
| Original URL snapshot | The incoming request context's origin, host, hostname, and canonicalOrigin. | Preserved unchanged through derived contexts and queues, independently of current worker configuration. |
dispatchId | Optional application-owned send or forward option. | Preserved through that invocation's descendants. New calls can reuse it without suppressing sends; a call that omits it does not inherit a previous invocation's value. |
broadcastId | Fresh ID for each observed public send/forward invocation or internal portable-replication invocation, before preparation. | Preserved through fanout replay and retries. Another public invocation creates a new broadcast, even for the same Activity. |
deliveryId | Allocated for each deduplicated destination or claimed replication target, before its handoff. | Preserved through fanout replay, native redelivery, retries, holds, fallback, and gateway-list changes. Alternative routes share a delivery; replication targets have separate deliveries. |
attemptId | Fresh ID at each concrete sender entry. | Never reused on retries or native redelivery, and never derived from a queue round or trace ID. |
exchangeIndex | Starts at zero and increments for actual fetch submissions within an attempt. | Identifies an exchange together with attemptId; redirects and signature challenges remain within their attempt. |
queueMessageId | The current queue task's actual UUID. | Fanout expansion or replay can create new task IDs without creating new logical deliveries. |
queueAttempt | The current message's Fedify retry counter, when available. | Native redelivery can leave it unchanged. One round can include multiple gateway attempts; it is not an execution ordinal. |
| Trace/span IDs | The observed operation's own valid OpenTelemetry span, when available. | Optional supplementary context. Worker and attempt reports use their own spans; trace IDs never replace grouping identities. |
IDs provide grouping, not ordering, deduplication, or remote idempotency. Callbacks can interleave or repeat, and a crash or operation that never settles can leave an invocation without a terminal report. Applications own persistence and aggregate delivery state.
A failed attempt receiving HTTP 503 and a later successful retry receiving HTTP 202 share the same deliveryId, but have different attemptId values. The worker report records the scheduled retry and its queue task ID.
Original source and current context
local describes the context invoking the callback. For inbox-derived work, provenance.inboxRequest preserves the original inboxRequestId, informational logging requestId, and URL snapshot through context clones, conversions, new sends, forwarding, and queues. Both URL snapshots contain origin, host, hostname, and canonicalOrigin. Independent contexts do not acquire an inbox cause from ambient execution. Worker logs use sourceInboxRequestId and sourceRequestId without replacing an unrelated current request's label. See observing inbox requests.
Send and forward options accept an application-owned dispatchId for grouping related calls, including manual retries. It is preserved exactly through outgoing queue continuations and never suppresses a send. For example, an inbox listener can associate an acceptance with its application record:
import { Accept, Follow } from "@fedify/vocab";
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.on(Follow, async (ctx, follow) => {
if (follow.id == null || follow.objectId == null) return;
const actor = ctx.parseUri(follow.objectId);
if (actor?.type !== "actor") return;
const follower = await follow.getActor(ctx);
if (follower == null) return;
const dispatchId = await saveAcceptance(follow);
await ctx.sendActivity(
{ identifier: actor.identifier },
follower,
new Accept({ actor: follow.objectId, object: follow.id }),
{ dispatchId },
);
});saveAcceptance() represents your application's record creation. Dispatch IDs may be empty but must fit within 1,024 UTF-8 bytes. Invalid types raise TypeError and oversized strings raise RangeError before preparation. A new call that omits the ID does not inherit it from a previous invocation.
To forward the received payload instead of constructing a new Activity, use the inbox handler's forwardActivity() method with the same application correlation option:
await ctx.forwardActivity(
{ identifier: "alice" },
"followers",
{ dispatchId, skipIfUnsigned: true },
);This creates a forward broadcast, preserves the received payload, and carries the original inbox provenance and dispatchId into outgoing observations and queue continuations. The example skips unsigned activities. See forwarding activities for delivery and signature considerations.
Older messages remain processable, with missing relationships explicitly unknown. Legacy fanout can recover stable delivery grouping; legacy outbox reports use the current task UUID as the delivery ID and cannot recover broadcast membership. Unavailable original destinations and source facts remain absent. If queued source metadata is malformed, the consumer warns and continues without its provenance; a retry carries the empty provenance rather than the invalid value. Older consumers can discard new metadata, so mixed-version processing can lose correlation. Source metadata and dispatch IDs propagate even without producer observers; broadcast and delivery metadata is allocated when producer observation is enabled.
Capturing headers
Header values are disabled by default. Configure requestHeaders and responseHeaders independently with literal names in allow and a positive safe-integer maxBytes. Optional redact names add application-specific secrets. Names are normalized to lowercase; wildcards are not supported.
federation.setOutboundObservers({
requestHeaders: { allow: ["content-type", "signature"], maxBytes: 2048 },
responseHeaders: { allow: ["content-type", "retry-after", "set-cookie"], maxBytes: 1024 },
onHttpExchange(_ctx, report) {
console.log(report.request.headers?.entries);
if (report.outcome.type === "response") {
console.log(report.outcome.response.headers?.entries);
console.log(report.outcome.response.headers?.limitReached);
}
},
});Credentials, signatures, and cookies in the built-in protected set are always redacted, even when allowlisted. Add custom credential headers to redact. Snapshots contain immutable name/value pairs in lexical name order, using the runtime's representation of repeated headers. The budget counts UTF-8 bytes of name + ": " + value + "\r\n" after redaction. Oversized entries are omitted whole, and limitReached records omissions. An absent snapshot means capture was disabled; an enabled empty allowlist produces an empty snapshot. Each signed request, challenge retry, and redirect captures its actual submitted headers.
Streaming response bodies
Body capture is disabled unless responseBodyCapture is supplied. It is independent of header capture and can be used without metadata callbacks. Provide onBody, a positive safe-integer maxBytes, and a finite positive timeoutMs. Fractional milliseconds round up; the rounded lifetime cannot exceed 2,147,483,647 ms. Invalid configuration preserves the previous registration.
federation.setOutboundObservers({
responseBodyCapture: {
maxBytes: 64 * 1024,
timeoutMs: 5000,
responses: "non-success-http-responses",
async onBody(_ctx, event) {
const reader = event.body.getReader();
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
await saveChunk(event.attemptId, event.exchangeIndex, chunk.value, event.signal);
}
} catch (error) {
if (!event.signal.aborted) throw error;
} finally {
reader.releaseLock();
}
},
},
onHttpExchange(_ctx, report) {
console.log(report.attemptId, report.exchangeIndex, report.bodyCapture);
},
});responses defaults to all when capture is enabled. The example selects non-success-http-responses, including intermediate challenges and redirects; selection does not predict the attempt's final result. Its limits are illustrative.
onBody receives the current context, grouping metadata, immutable HTTP metadata, a controlled stream, and a capture-only signal. It runs after response headers and before the exchange report. Fedify uses one source reader for capture and diagnostics, with backpressure and no clone, tee, or whole-body accumulation. Runtime buffering, upstream chunk allocation, and application storage remain outside these bounds.
An absent bodyCapture means capture was disabled or no response was received. When present, its summary distinguishes filtered capture, a missing body, EOF, a byte limit, a deadline, early callback return, consumer cancellation, and body or observer failure. bytesExposed counts bytes handed to application code, not bytes durably saved. Reaching the exact cap does not establish EOF, so limit-reached leaves completeness uncertain.
The lifetime includes waiting for a consumer that is slow or never reads, as well as cleanup. Callback settlement, consumer cancellation, the cap, or the deadline revokes access even through retained locked readers. Late promise rejections are handled, but application storage that ignores the signal can continue. Applications must handle interrupted writes and archive cleanup themselves.
Capture failures retain observed HTTP facts and do not independently trigger a retry or skip a challenge. Error diagnostics have a separate one-second budget and retain at most 1 KiB, appending … (timed out) or … (truncated) as appropriate. Waiting for capture on multiple exchanges can add to total send time.
Inspecting the sender payload
Enable senderPayload: true to include a privacy-filtered copy of the concrete sender's existing JSON input in attempt reports. It covers immediate and queued sends, forwarding, and portable replication without additional serialization or context loading:
federation.setOutboundObservers({
senderPayload: true,
onAttempt(_ctx, report) {
const document = report.senderPayload;
if (document.type === "available") {
console.log(document.stage, document.representation, document.redacted);
console.log(document.value);
} else {
console.log("Sender payload unavailable", document.reason);
}
},
});An available payload is an immutable privacy-projection. It removes compact, as:-prefixed, and full-IRI bto/bcc fields throughout nested content, including proof-bearing objects. redacted indicates that fields were removed. It does not modify transport or signing input, establish proof validity, or represent exact wire bytes. Redirects can change the actual request body independently.
Unsupported contexts, aliases, getters, custom JSON conversion, cyclic data, or excessive nesting fail closed with privacy-filter-unavailable; there is no raw fallback. disabled means export was not enabled, and not-materialized means the sender did not reach the snapshot boundary. Supported context forms and field definitions are described by OutboundSenderPayload.
Headers, bodies, and payloads remain privileged application data. URLs and content can contain private values beyond blind recipients. Your application owns access control, additional redaction, archival, and saved original dispatch documents.
Registration and callback behavior
Registering again replaces the complete observer set; pass {} to clear it. Builders copy registrations into built federations, and active invocations and workers retain their selected callbacks. Any callback can be registered alone. Mock federations accept registration but do not execute these observers.
Observer errors, including logging errors, are isolated from the original result and retry ownership. Metadata callbacks are awaited without a default timeout; a callback that never settles can delay processing indefinitely. Detached recording must handle its own rejections and shutdown. Neither awaited recording nor detached work makes persistence atomic with network effects or queue acceptance. Portable-forwarding deadlines can return control while sends and observation continue; the deadline alone does not create a failed attempt.
Each event or report has an observedAt Unix timestamp in milliseconds. It does not establish ordering across workers. Observation works without OpenTelemetry or sampling. Optional trace references the operation's own valid span, including unsampled spans; broadcast starts and HTTP exchanges omit it because they have no span of their own at those boundaries.
Inbox request cancellation is observed separately from processing. Queue intake shutdown and per-execution cancellation are also distinct. Execution cancellation is not currently reported by this API. Incoming C2S setOutboxListeners() registration serves a separate purpose from outbound observation.
Optimizing activity delivery for large audiences
This API is available since Fedify 1.5.0.
When sending activities to many recipients (such as when a user with thousands of followers creates a post), the delivery process can become performance-intensive. Fedify optimizes this scenario by using a fan-out mechanism that improves response times and resource utilization.
How fan-out works
By default, when the number of recipients exceeds a threshold, Fedify uses a two-stage delivery process:
- First, it creates a single consolidated message containing the activity payload and all recipient inboxes
- Then, a background worker processes this message and re-enqueues individual delivery tasks
This approach has several benefits:
- The
Context.sendActivity()method returns more quickly - Memory consumption is reduced by avoiding payload duplication
- The user interface remains responsive during large-scale deliveries
- Each delivery still maintains independent retry logic
Customizing fan-out behavior
You can control this behavior using the fanout option in the sendActivity() method:
await ctx.sendActivity(
{ identifier: "alice" }, // sender
recipients, // recipients
activity, // activity to send
{ fanout: "auto" } // fan-out strategy
);The fanout option accepts the following values:
"auto"(default)- Automatically chooses the optimal strategy based on recipient count
"skip"- Always enqueues individual messages, bypassing the fan-out queue (use when payload needs to vary per recipient)
"force"- Always uses the fan-out queue regardless of recipient count
NOTE
The fanout option is ignored when immediate: true is specified, as immediate delivery bypasses all queuing mechanisms.
When to use each option
Use the default "auto" for most cases:
await ctx.sendActivity({ identifier: "alice" }, recipients, activity);Use "skip" when you need different content for each recipient:
await ctx.sendActivity(
{ identifier: "alice" },
recipients,
activity,
{ fanout: "skip" }
);Use "force" to ensure fan-out behavior even with few recipients (rarely needed):
await ctx.sendActivity(
{ identifier: "alice" },
recipients,
activity,
{ fanout: "force" }
);Ensuring ordered delivery
This API is available since Fedify 2.0.0.
When sending multiple related activities for the same object (e.g., Create(Note) followed by Delete(Note) for the same Note), it's important that they arrive at recipient servers in the correct order. Without ordering guarantees, a Delete(Note) activity might arrive before the Create(Note) activity, causing the recipient to ignore the deletion and leaving a “zombie post” that should have been deleted.
To ensure ordered delivery, you can use the orderingKey option in the sendActivity() method:
// Create activity
await ctx.sendActivity(
{ identifier: "alice" },
recipients,
new Create({
id: new URL("#create", noteId),
actor: ctx.getActorUri("alice"),
object: new Note({ id: noteId }),
}),
{ orderingKey: noteId.href },
);
// Delete activity - guaranteed to arrive after Create
await ctx.sendActivity(
{ identifier: "alice" },
recipients,
new Delete({
id: new URL("#delete", noteId),
actor: ctx.getActorUri("alice"),
object: noteId,
}),
{ orderingKey: noteId.href },
);Activities with the same orderingKey are guaranteed to be delivered in the order they were enqueued, per recipient server. Activities with different orderingKey values (or no orderingKey) can be delivered in parallel for maximum throughput.
When to use orderingKey
Use orderingKey when you have activities that must be processed in a specific order:
Create(Note)→Update(Note)→Delete(Note)for the same objectFollow(Person)→Undo(Follow(Person))for the same target- Any sequence where later activities depend on earlier ones
Don't use orderingKey for unrelated activities, as it unnecessarily reduces parallelism.
How it works
When you specify an orderingKey:
- During fan-out, the key is passed as-is to the fanout queue
- When individual delivery tasks are created, the key is transformed to
${orderingKey}\n${recipientServerOrigin}to ensure ordering is maintained per-recipient-server while allowing parallel delivery to different servers
This means Server A can receive a Delete(Note) immediately after its Create(Note) completes, without waiting for Server Z's Create(Note) to finish.
Immediately sending an activity
Sometimes you may want to send an activity immediately without queueing it. You can do this by calling the sendActivity() method with the immediate option:
import { type Context } from "@fedify/fedify";
import { Follow, type Recipient } from "@fedify/vocab";
async function sendFollow(
ctx: Context<void>,
senderId: string,
recipient: Recipient,
) {
await ctx.sendActivity(
{ identifier: senderId },
recipient,
new Follow({
actor: ctx.getActorUri(senderId),
object: recipient.id,
}),
{ immediate: true },
);
}Shared inbox delivery
The shared inbox delivery is an efficient way to deliver an activity to multiple recipients belonging to the same server at once. It is useful for broadcasting activities, such as a public post.
By default, sendActivity() method delivers an activity to the recipient's personal inbox. To deliver an activity to the shared inbox, you can pass the preferSharedInbox option:
import { type Context } from "@fedify/fedify";
import { Create, Note, type Recipient, PUBLIC_COLLECTION } from "@fedify/vocab";
async function sendNote(
ctx: Context<void>,
senderId: string,
recipient: Recipient,
) {
await ctx.sendActivity(
{ identifier: senderId },
recipient,
new Create({
actor: ctx.getActorUri(senderId),
to: PUBLIC_COLLECTION,
object: new Note({
attribution: ctx.getActorUri(senderId),
to: PUBLIC_COLLECTION,
}),
}),
{ preferSharedInbox: true },
);
}TIP
PUBLIC_COLLECTION constant contains a URL object of https://www.w3.org/ns/activitystreams#Public, a special IRI that represents the public audience. By setting the to property to this IRI, the activity is visible to everyone. See also the Public Addressing section in the ActivityPub specification.
NOTE
To deliver an activity to the shared inbox, the recipient server must support the shared inbox delivery. Otherwise, Fedify silently falls back to the personal inbox delivery.
Followers collection synchronization
This API is available since Fedify 0.8.0, and it is optional since Fedify 1.5.0.
NOTE
For efficiency, you should implement filtering-by-server of the followers collection, otherwise the synchronization may be slow.
If an activity needs to be delivered to only followers of the sender through the shared inbox, the server of the recipients has to be aware of the list of followers residing on the server. However, synchronizing the followers collection every time an activity is sent is inefficient. To solve this problem, Mastodon, etc., use a mechanism called followers collection synchronization.
The idea is to send a digest of the followers collection with the activity so that the recipient server can check if it needs to resynchronize the followers collection. Fedify provides a way to include the digest of the followers collection in the activity delivery request by specifying the recipients parameter of the sendActivity() method as the "followers" string and turning on the syncCollection option:
await ctx.sendActivity(
{ identifier: senderId },
"followers",
new Create({
actor: ctx.getActorUri(senderId),
to: ctx.getFollowersUri(senderId),
object: new Note({
attribution: ctx.getActorUri(senderId),
to: ctx.getFollowersUri(senderId),
}),
}),
{
preferSharedInbox: true,
syncCollection: true,
},
);The syncCollection option is only available when you specify the "followers" string as the recipients parameter. With turning on this option, it automatically sends the activity to the sender's followers and includes the digest of the followers collection in the payload.
NOTE
The to and cc properties of an Activity and its object should be set to the followers collection IRI to ensure that the activity is visible to the followers. If you set the to and cc properties to the PUBLIC_COLLECTION, the activity is visible to everyone regardless of the recipients parameter.
NOTE
Some history of this feature: The followers collection synchronization was first introduced in Fedify 0.8.0, but it was automatically turned on when the recipients parameter was set to the "followers" string then. Since Fedify 1.5.0, it is optional, and you need to explicitly turn on the syncCollection option to use it.
Excluding same-server recipients
This API is available since Fedify 0.9.0.
Usually, you don't want to send messages through ActivityPub to followers on the same server because they share the same database, so there's no need to.
For example, if @foo@example.com creates a post, it's already stored in the database at example.com, so there's no need to send a Create(Note) activity to @bar@example.com, because @bar@example.com already has access to the post in the database.
To exclude same-server recipients, you can pass the excludeBaseUris option to the sendActivity() method:
await ctx.sendActivity(
{ identifier: senderId },
"followers",
activity,
{ excludeBaseUris: [ctx.getInboxUri()] },
);Excluded recipients do not receive the activity, even if they are included in the recipients parameter.
NOTE
Only the origin parts of the specified URIs are compared with the inbox URLs of the recipients. Even if they have pathname or search parts, they are ignored when comparing the URIs.
Delivering to portable actors
This API is available since Fedify 2.4.0.
An FEP-ef61 portable actor has an ap: or ap+ef61: URI as its inbox, e.g., ap://did:key:z6Mk.../actor/inbox, which no server can receive a POST request at. Instead, the servers listed in the actor's gateways property accept deliveries to the inbox at its compatible identifier, e.g., https://gateway.example/.well-known/apgateway/did:key:z6Mk.../actor/inbox, and forward them to each other.
sendActivity() and forwardActivity() deliver to such an inbox automatically. Fedify takes the gateways from the recipient's gateways, in order, or, if it has no valid gateway, from the @gateway location hints of the inbox URI, and tries at most five of them one after another until one accepts the activity. In particular, a gateway that responds with 404 Not Found, which FEP-ef61 uses for a server that does not accept deliveries on behalf of the actor, is not tried again for the activity. If no gateway accepts the activity, the whole round of gateways is retried according to the retry policy, so that the round counts as one attempt. A recipient with no gateway to deliver through is skipped with a warning.
Fedify delivers to the gateways that the recipient object names, just as it delivers to any inbox URL that a recipient object names. So make sure that recipient objects of portable actors come from their verified actor documents, e.g., through lookupObject(). If you implement a followers collection dispatcher that returns Recipient objects instead of actors, include the gateways of portable followers along with their inboxId.
A few options behave differently for portable actors:
preferSharedInbox- Ignored for a recipient whose inbox is an
ap:orap+ef61:URI, since FEP-ef61 has no shared inbox and gateways synchronize only the actor's own inbox. excludeBaseUris- Compared with the origins of the recipient's gateways, so that the activity is not delivered directly to the excluded gateways. The other gateways may still forward it to them. A recipient whose gateways are all excluded is skipped.
orderingKey- Keeps deliveries to a portable inbox in order by the actor's DID rather than by the origin of a gateway, so that the order holds whichever gateway an activity goes through.
The gateways are fixed when the activity is sent, so queued deliveries do not follow later changes of the recipient's gateways. Queue workers of older Fedify versions deliver such queued activities only to the first gateway; upgrade the workers before the servers that enqueue deliveries if you run them separately.
Error handling
This API is available since Fedify 0.6.0.
Since an outgoing activity is not immediately processed, but enqueued to the queue, the sendActivity() method does not throw an error even if the delivery fails. Instead, the delivery failure is reported to the queue and retried later.
If you want to handle the delivery failure, you can register an error handler to the queue:
import { createFederation, InProcessMessageQueue } from "@fedify/fedify";
const federation = createFederation({
// Omitted for brevity; see the related section for details.
queue: new InProcessMessageQueue(),
onOutboxError: (error, activity) => {
console.error("Failed to deliver an activity:", error);
console.error("Activity:", activity);
},
});NOTE
The onOutboxError callback can be called multiple times for the same activity, because the delivery is retried according to the backoff schedule until it succeeds or reaches the maximum retry count.
Permanent delivery failure handling
This API is available since Fedify 2.0.0.
When a remote inbox returns an HTTP status code that indicates a permanent failure (such as 410 Gone or 404 Not Found), there is no point in retrying the delivery. Fedify automatically skips retries for permanent failures and allows you to handle them by registering a permanent failure handler:
import { createFederation, InProcessMessageQueue } from "@fedify/fedify";
const federation = createFederation({
// Omitted for brevity; see the related section for details.
queue: new InProcessMessageQueue(),
});
federation.setOutboxPermanentFailureHandler(async (ctx, values) => {
console.warn(
`Inbox ${values.inbox.href} permanently failed ` +
`with status ${values.statusCode}`
);
// Remove followers associated with this inbox:
for (const actorId of values.actorIds) {
await removeFollower(actorId);
}
});The handler receives the following information:
values.inbox- The inbox URL that returned the permanent failure.
values.activity- The
Activityobject that failed to deliver. values.error- The
SendActivityErrorthat was thrown. values.statusCode- The HTTP status code returned by the inbox (e.g.,
404or410). values.actorIds- The actor IDs that were supposed to receive the activity at this inbox. When
preferSharedInbox: trueis used, a single inbox URL may represent multiple followers.
By default, the following HTTP status codes are treated as permanent failures:
404 Not Found- The inbox no longer exists.
410 Gone- The inbox is explicitly marked as gone.
You can customize which status codes are treated as permanent failures using the permanentFailureStatusCodes option in FederationOptions:
import { createFederation, InProcessMessageQueue } from "@fedify/fedify";
const federation = createFederation({
// Omitted for brevity; see the related section for details.
queue: new InProcessMessageQueue(),
permanentFailureStatusCodes: [404, 410, 451],
});See also the permanentFailureStatusCodes option in the Federation section for more details.
NOTE
The permanent failure handler is called only once for each permanent failure, and delivery is not retried afterwards. This is different from onOutboxError, which can be called multiple times due to retries.
Even if no handler is registered, permanent failures will still skip retries.
TIP
If any errors are thrown in the permanent failure handler, they are caught, logged, and ignored, similar to onOutboxError.
HTTP Signatures
Draft cavage HTTP Signatures is a de facto standard for signing ActivityPub activities. Although it is not a finalized specification, it is still widely used in the fediverse to verify the sender's identity and the integrity of the activity.
Fedify automatically signs activities with the sender's private key if the actor keys dispatcher is set and the actor has any RSA-PKCS#1-v1.5 key pair. If there are multiple key pairs, Fedify selects the first RSA-PKCS#1-v1.5 key pair among them.
HTTP Message Signatures
This API is available since Fedify 1.6.0.
RFC 9421, also known as HTTP Message Signatures, is the final revision of the HTTP Signatures specification. Although it is the official standard, it is not widely used in the fediverse yet. As of May 2025, major ActivityPub implementations, such as Mastodon, et al., still rely on the draft cavage version of HTTP Signatures for signing portable activities.
Fedify automatically signs activities with the sender's private key if the actor keys dispatcher is set and the actor has any RSA-PKCS#1-v1.5 key pair. If there are multiple key pairs, Fedify selects the first RSA-PKCS#1-v1.5 key pair among them.
NOTE
Although HTTP Message Signatures support other than RSA-PKCS#1-v1.5, Fedify currently supports only RSA-PKCS#1-v1.5 key pairs for generating HTTP Message Signatures. This limitation will be lifted in the future releases.
Double-knocking HTTP Signatures
This API is available since Fedify 1.6.0.
As you read above, there are two revisions of HTTP Signatures: the draft cavage version and the RFC 9421 version. The draft cavage version is declared as obsolete, but it is still widely used in the fediverse, and many ActivityPub implementations still rely on it. On the other hand, the RFC 9421 version is the official standard, but it is not widely used yet.
To support both versions of HTTP Signatures, Fedify uses the double-knocking mechanism: trying one version, then falling back to another if rejected. If it's the first encounter with the recipient server, Fedify tries the RFC 9421 version first, and if it fails, it falls back to the draft cavage version. If the recipient server accepts the RFC 9421 version, Fedify remembers it and uses the RFC 9421 version for the next time. If the recipient server rejects the RFC 9421 version, Fedify falls back to the draft cavage version and remembers it for the next time.
Accept-Signature negotiation
This API is available since Fedify 2.1.0.
In addition to double-knocking, Fedify supports the Accept-Signature challenge-response negotiation defined in RFC 9421 §5. When a recipient server responds with a 401 status and includes an Accept-Signature header, Fedify automatically parses the challenge, validates it, and retries the request with the requested signature parameters (e.g., specific covered components, a nonce, or a tag).
Safety constraints prevent abuse:
- The requested algorithm (
alg) must match the local private key's algorithm; otherwise the challenge entry is skipped. - The requested key identifier (
keyid) must match the local key; otherwise the challenge entry is skipped. - Fedify's minimum covered component set (
@method,@target-uri,@authority) is always included, even if the challenge does not request them.
If the challenge cannot be fulfilled (e.g., incompatible algorithm), Fedify falls through to the existing double-knocking spec-swap fallback. At most three signed request attempts are made to the final URL per delivery attempt (redirects may add extra HTTP requests):
- Initial signed request
- Challenge-driven retry (if
Accept-Signatureis present) - Legacy spec-swap retry (if the challenge retry also fails)
Linked Data Signatures
This API is available since Fedify 1.0.0.
Linked Data Signatures is a more advanced and widely used, but obsolete, mechanism for signing portable ActivityPub activities. As of November 2024, major ActivityPub implementations, such as Mastodon, et al., still rely on Linked Data Signatures for signing portable activities, despite they declare that Linked Data Signatures is outdated.
It shares the similar concept with HTTP Signatures, but unlike HTTP Signatures, it can be used for signing portable activities. For example, it can be used for forwarding from inbox and several other cases that HTTP Signatures cannot handle.
Fedify automatically includes the Linked Data Signature of activities by signing them with the sender's private key if the actor keys dispatcher is set and the actor has any RSA-PKCS#1-v1.5 key pair. If there are multiple key pairs, Fedify uses the first RSA-PKCS#1-v1.5 key pair among them.
TIP
The combination of HTTP Signatures and Linked Data Signatures is the most widely supported way to sign activities in the fediverse, as of September 2024. Despite Linked Data Signatures is outdated and not recommended for new implementations, it is still widely used in the fediverse due to Mastodon and other major implementations' reliance on it.
However, for new implementations, you should consider using both Object Integrity Proofs and Linked Data Signatures for maximum compatibility and future-proofing. Fortunately, Fedify supports both Object Integrity Proofs and Linked Data Signatures simultaneously, in addition to HTTP Signatures.
NOTE
If an activity is signed with both HTTP Signatures and Linked Data Signatures, the recipient verifies the Linked Data Signatures first when it is supported, and ignores the HTTP Signatures if the Linked Data Signatures are valid. If the recipient does not support Linked Data Signatures, it falls back to verifying the HTTP Signatures.
Object Integrity Proofs
This API is available since Fedify 0.10.0.
Object Integrity Proofs is a mechanism to ensure the integrity of ActivityPub objects (not only activities!) in the fediverse. It shares the similar concept with Linked Data Signatures, but it has more functionalities and is more flexible. However, as it is relatively new, it is not widely supported yet.
Fedify automatically includes the integrity proof of activities by signing them with the sender's private key if the actor keys dispatcher is set and the actor has any Ed25519 key pair. If there are multiple key pairs, Fedify creates the number of integrity proofs equal to the number of Ed25519 key pairs. An activity containing FEP-ef61 portable objects is the exception: it gets at most one proof, as described in Choosing the proof key.
When verifying incoming Object Integrity Proofs, Fedify can resolve Ed25519 did:key verification methods locally. A proof whose verificationMethod is a DID URL such as did:key:z...#z... does not require fetching that verification method as a JSON-LD document. This is the key lookup mechanism used by FEP-ef61 portable objects; policy checks that relate a portable object ID to its proof are handled separately from proof verification itself. When verifyObject() checks whether a proof authenticates an object's actor or attribution, it treats matching portable ap:/ap+ef61: IDs and did:key controllers as the same FEP-fe34 cryptographic origin.
verifyProof() authenticates every received proof option except proofValue as part of the Ed25519 JCS signature. It rejects expired proofs and malformed standard options. When a proof is bound to a security domain or challenge, pass the expected domain or challenge through VerifyProofOptions; a missing or different value makes verification fail.
For received portable objects, use verifyPortableObjectProof(). It keeps verifyProof() focused on cryptographic verification while also enforcing the FEP-ef61 policy: a portable actor, activity, or object must have proofs, every proof must use a DID URL, and that DID must match the authority of the portable object ID. A portable actor must also have a non-empty gateways list whose items are all HTTP(S) URIs with an empty path, query, and fragment; this is checked before the proofs, so the invalidGateways reason does not mean that the proofs are valid. When a portable actor omits the JSON-LD mapping for gateways, as tootik v0.25.4 does, Fedify accepts the otherwise unmapped ActivityStreams term only if every proof uses eddsa-jcs-2022. These proofs authenticate the original JSON, including the gateway list.
The detailed result distinguishes documents outside the policy, missing or invalid proofs, actors without valid gateways, unsupported verification methods, DID mismatches, and successful verification:
import { verifyPortableObjectProof } from "@fedify/fedify";
async function verifyPortableResponse(response: Response): Promise<void> {
const jsonLd: unknown = await response.json();
let result;
try {
result = await verifyPortableObjectProof(jsonLd);
} catch (error) {
if (error instanceof TypeError) {
throw new Error("Malformed portable object.", { cause: error });
}
throw error;
}
if (result.verified) {
console.log("Verified with", result.keys);
} else if (result.reason.type === "unsecuredCollection") {
// Apply the gateway trust policy for an unsigned portable collection.
} else if (result.reason.type !== "notPortableObject") {
throw new Error(`Portable proof rejected: ${result.reason.type}`);
}
}A portable collection that has proofs is verified in the same way. A portable collection without a proof produces the unsecuredCollection result so the caller can apply the separate gateway trust policy allowed by FEP-ef61. verifyPortableObject() combines both: it verifies proofs like verifyPortableObjectProof(), and applies the gateway trust policy to unsecured collections, given where the collection was retrieved from. Property accessors pass that information automatically when you use it as their verifyPortableObject() option; see the Portable collections section of the vocabulary manual. verifyPortableObjectProof() examines only the top-level JSON-LD node. An embedded portable object needs its own verification; success for an outer activity does not authenticate portable objects nested inside it.
Compound portable objects
For compound documents containing FEP-ef61 portable objects, Fedify inboxes use a bounded, map-local interoperability profile. Neither FEP-8b32 nor Verifiable Credential Data Integrity currently specifies this compound-document algorithm.
The profile treats each JSON map with a direct literal proof property as a separate secured document. Verification removes only that map's proof and leaves proofs on descendant maps in place. Replacing a valid child proof therefore invalidates an outer proof that covered the original secured child. Fedify requires a direct proof. It does not treat proof aliases, sets, chains, or remote references as alternatives.
Every embedded portable map, i.e., a map whose id or @id is an ap: or ap+ef61: URI or an FEP-ef61 compatible identifier, needs one direct inline proof and its own explicit @context. The exception is a key embedded in the publicKey or assertionMethod of a portable actor, such as a gateway key, whose ID is the actor's compatible identifier plus a fragment; the actor's proof covers it. Fedify verifies each map against an immutable copy of the received JSON and applies the FEP-ef61 portable-ID and controlling-DID policy to that map. It does not copy a parent's context into a child, try several proof-removal rules, or fetch an unpinned context from the live network. Inputs that exceed the traversal limits are rejected. Fedify does not return authentication results for only part of a compound document.
This profile authenticates JSON snapshots. A child proof may be valid when verified in isolation even if the parent's active context causes the embedded JSON-LD to expand differently. Proof success does not establish that the isolated and embedded expansions are equivalent.
This profile is Fedify's interim interpretation, and may change in Fedify 3.0. See the Map-local compound proofs section of the Portable objects chapter.
Producing a compound document
signObject() captures the secured JSON document that the proof it creates covers, and keeps it on the object it returns. Assigning that object to a typed parent and serializing the parent embeds the captured document verbatim, so the child keeps its own @context and its own proof context even when they differ from the parent's:
const note = await signObject(
new Note({ id: noteId, attribution: actorId, content: "Hello" }),
key,
keyId,
{ context: portableContext },
);
const create = await signObject(
new Create({ id: activityId, actor: actorId, object: note }),
key,
keyId,
{ context: portableContext },
);
const compound = await create.toJsonLd({
format: "compact",
context: portableContext,
});Extracting compound's object gives back exactly the bytes note's proof covers, and the outer proof covers that same secured child.
Serialize the parent with the same context it was signed with. A proof covers one serialization, and a document emitted under a different context is a document the proof was never computed over. This matters most when the activity goes out through sendActivity(), which serializes with the type's default context: omit the context option on the outer signObject() call in that case, so the proof covers the bytes Fedify actually sends. The child keeps its own context either way, which is what makes a mismatch on the parent easy to misread as a working document.
The captured document is a snapshot. It is independent of anything done to the returned object afterwards, and clone() never carries it, because a clone may differ from the document the proof covers. Sign the clone again when it has to be embedded as a secured child.
Choosing the proof key
Outside the compound profile, sendActivity() signs an activity once for each Ed25519 key it is given, which yields a proof set when there are several keys. Fedify inboxes reject a proof set in a document that contains a portable object, so an activity whose JSON contains a map identified by an ap: or ap+ef61: URI or an FEP-ef61 compatible identifier, whether the activity itself or anything embedded in it, gets at most one proof:
- An activity that already carries a proof is sent as is. Fedify does not add another proof to it, not even with the keys from the actor key pairs dispatcher.
- A portable activity, i.e., one whose ID is an
ap:orap+ef61:URI or a compatible identifier, is signed only by the key whose ID is a DID URL for the activity's own DID, such asdid:key:z6Mk…#z6Mk…forap://did:key:z6Mk…/activities/1orhttps://example.com/.well-known/apgateway/did:key:z6Mk…/activities/1, even if it is the only Ed25519 key. This is the only proof FEP-ef61 accepts for it. - With a single Ed25519 key, a non-portable activity is signed by that key.
When no key or more than one key qualifies, or when a non-portable activity embeds portable objects and several Ed25519 keys are available, sendActivity() rejects with a TypeError instead of guessing. Nothing is delivered or queued in that case. To choose the key yourself, pass explicit sender keys that contain exactly one Ed25519 key, or sign the activity with signObject() before sending it. RSA keys in the same list keep signing the HTTP request and the Linked Data Signature as usual:
await ctx.sendActivity(
[
{
keyId: new URL("https://example.com/users/alice#main-key"),
privateKey: rsaPrivateKey,
},
{
keyId: new URL("did:key:z6Mkabc#z6Mkabc"),
privateKey: ed25519PrivateKey,
},
],
recipient,
activity,
);Regardless of the activity, keys whose IDs are FEP-ef61 compatible identifiers, such as the gateway keys of portable actors, never make proofs or Linked Data Signatures; they only sign HTTP requests. An activity whose actor is a portable actor, i.e., one whose ID is an ap: or ap+ef61: URI or a compatible identifier, never gets a Linked Data Signature either, and it has to have a portable ID or a compatible identifier of the actor's DID; otherwise sendActivity() rejects with a TypeError. The two forms may be mixed, as receivers compare the DIDs, not the forms or the gateways. A malformed compatible identifier, e.g., one with @gateway location hints, is rejected too.
The Multikey IDs Fedify derives for the actor key pairs dispatcher are fragments of the actor URI, such as …/actor#multikey-1, or, for portable actors, gateway key IDs, not DID URLs. An unsigned portable activity sent by actor identifier is therefore always rejected; send it with explicit sender keys or pre-sign it instead.
Fedify also refuses to send an activity with portable objects if any map in it already carries a proof set, which happens, for example, when signObject() is called twice on the same object. The error names the JSON Pointer of the offending proof. For the same reason, it refuses to send one in which an embedded map carries a proof but not its own @context, which Fedify inboxes reject however valid the proof is. An object that you signed with signObject() is embedded with its own @context, but one parsed from a received document is rebuilt under the activity's context, so its proof no longer covers it (see the warning below). This typically happens to an Accept that embeds the Follow it accepts, as Fedify signs activities with Object Integrity Proofs by default, so refer to such an object by its ID instead, e.g., new Accept({ object: follow.id }). These checks only prevent unsupported proof shapes. They do not otherwise validate a proof created with a single key.
WARNING
Several things take a signed child outside this supported path, and each one falls back to ordinary serialization, which rebuilds the child under the parent's context and leaves it unable to verify on its own:
- An object parsed with
fromJsonLd(). Parsing does not establish which representation was signed, so nothing is captured. When forwarding an already signed payload, useforwardActivity()to avoid a vocabulary-object round trip. - An object that already carried a proof. The profile accepts exactly one direct proof per map, so sign each object with exactly one key. Fedify refuses to send an activity whose portable content carries a proof set; see Choosing the proof key.
- A
toJsonLd()call whosecontextoption could hide the marker Fedify uses to place the captured document, for example a context that aliases@idunder a term other thanid, declares@nest, uses an@idor@typecontainer, or cannot be resolved from Fedify's preloaded contexts. - A mutation applied to the returned object in place, through a plural accessor's array or a
proofValuebyte. The snapshot still holds what was signed, so the embedded child keeps verifying while the typed object no longer matches it.
TIP
HTTPS Signatures, Linked Data Signatures, and Object Integrity Proofs can coexist in an application and be used together for maximum compatibility.
If an activity is signed with HTTP Signatures, Linked Data Signatures, and Object Integrity Proofs, the recipient verifies the Object Integrity Proofs first when it is supported, and ignores the HTTP Signatures and Linked Data Signatures if the Object Integrity Proofs are valid. If the recipient does not support Object Integrity Proofs, it falls back to verifying the HTTP Signatures and Linked Data Signatures.
To support HTTP Signatures, Linked Data Signatures, and Object Integrity Proofs simultaneously, you need to generate both RSA-PKCS#1-v1.5 and Ed25519 key pairs for each actor, and store them in the database.
Activity transformers
This API is available since Fedify 1.4.0.
Activity transformers are a way to adjust activities before sending them to the recipients. It is useful for modifying the activity to fit the recipient's ActivityPub implementation (which may have some quirks) or for adding some additional information to the activity.
The activity transformers are applied before they are signed with the sender's private key and sent to the recipients.
Fedify also applies a small set of internal JSON-LD wire-format compatibility fixes after serializing the transformed activity. Unlike activity transformers, these fixes operate on the compact JSON-LD document rather than the Activity object, so they can preserve representation details such as array-valued properties that JSON-LD compaction would otherwise collapse. These internal fixes are applied automatically after serialization for unsigned activities and for proofs Fedify creates while sending.
Activities that already carry cryptographic proofs are sent unchanged by default, so the compact JSON-LD bytes stay consistent with the existing signature. If you pre-sign an activity locally with signObject() or createProof() and then pass it to Context.sendActivity(), set normalizeExistingProofs: true so the outgoing wire form matches the normalized bytes covered by the proof.
When an outgoing document uses custom or inline JSON-LD contexts, Fedify may canonicalize the document before and after a representation fix to confirm that the fix preserves JSON-LD semantics. Documents that are too deeply nested for safe traversal are sent unchanged instead.
Activity transformers can be configured by setting the activityTransformers option. By default, the following activity transformers are enabled:
autoIdAssigner()
This activity transformer automatically assigns a unique IRI to the activity if the id property is not set. It is useful for ensuring that the activity has a unique IRI, which is required by the ActivityPub specification.
The generated IRI is a URN UUID like:
urn:uuid:12345678-1234-5678-1234-567812345678actorDehydrator()
This activity transformer dehydrates
the actor property of the activity so that it only contains the actor's URI (rather than the full actor object inlined). It is useful for satisfying some ActivityPub implementations like Threads that have quirks, which fail to parse the activity if the actor property contains the full actor object inlined.
For example, the following activity:
new Follow({
id: new URL("http://example.com/activities/1"),
actor: new Person({
id: new URL("http://example.com/actors/1"),
name: "Alice",
preferredUsername: "alice",
}),
object: new Person({
id: new URL("http://example.com/actors/2"),
name: "Bob",
preferredUsername: "bob",
}),
});is transformed into:
new Follow({
id: new URL("http://example.com/activities/1"),
actor: new URL("http://example.com/actors/1"),
object: new Person({
id: new URL("http://example.com/actors/2"),
name: "Bob",
preferredUsername: "bob",
}),
});Previously known as
Unlisted
in Mastodon—renamed toQuiet public
in Mastodon 4.3.0. It's a public post that doesn't appear in the public timeline and the hashtag pages. ↩︎