Writing a Connector
Daymark ships with seven built-in mocked destinations (Bluesky, Mastodon, Instagram, YouTube, TikTok, Threads, X) — none of them make a real network call. Core Daymark deliberately owns no social API credentials or OAuth flows (see the plugin's non-goals). A real destination is added by a separate plugin that implements one interface and registers itself on init — this page is a complete, minimal example of doing that.
The contract
A connector implements Daymark_Syndication_Connector
(includes/connectors/interface-syndication-connector.php):
interface Daymark_Syndication_Connector {
public function get_id(): string;
public function get_label(): string;
public function supports_daymark_type( string $type ): bool;
public function is_connected(): bool;
public function publish( int $post_id, array $payload ): array;
public function get_status_label(): string;
}
get_id()— a unique machine slug, e.g.'my-network'.get_label()— the human-readable name shown in the composer's destination list.supports_daymark_type( $type )— whether this destination can represent a given Mark type (note,image,video,audio,gallery,mixed). Daymark checks this both in the UI (to grey out an unsupported toggle) and again server-side before ever callingpublish().is_connected()—trueonly once real credentials are configured. A connector that returnsfalsehere is never offered as a publish destination and never receives type-based auto-defaults (see "Destination visibility" in CLAUDE.md) — it can still be selected explicitly via the REST API, in which casepublish()should fail gracefully rather than assume a live session.publish( $post_id, $payload )— must never throw. Always return a result array, even on failure.get_status_label()— a short UI string, e.g.'Connected'or'Not connected'.
The publish() payload
$payload is the same Mark context Daymark's own daymark_published action
receives — build against these keys, not undocumented internals:
array(
'post_id' => 123,
'primary_type' => 'image', // note|image|video|audio|gallery|mixed
'media_ids' => array( 45, 46 ), // attachment IDs, in display order
'caption' => 'A day at the coast.',
'syndication_targets' => array( 'my-network' ), // every selected destination this publish
'default_destinations' => array( 'my-network' ), // type-based defaults that applied
'post_status' => 'publish', // or 'draft' — drafts never syndicate
'ai_assist_used' => false,
'created_from' => 'mobile',
)
Resolve the Mark's permalink and any per-image alt text from $post_id
itself (get_permalink(), get_post_meta()) rather than expecting them on
the payload — it deliberately carries only what routing/composition needs,
not the full post.
The result shape
return array(
'success' => true,
'external_id' => 'abc123',
'external_url' => 'https://example.com/posts/abc123',
'status' => 'published', // 'published' | 'mocked' | 'failed'
'message' => '',
'backflow_supported' => true, // optional — omit or false if replies can't be pulled back
);
Every attempted destination is recorded in _daymark_external_posts —
success, failure, an unsupported Mark type, or a destination that's since
been deactivated all show up there and in the Timeline's own routing
popover (see "Per-Mark routing transparency" in CLAUDE.md). Returning a
result rather than throwing is what keeps one failing connector from ever
blocking the rest of a Mark's selected destinations.
A minimal complete example
<?php
/**
* Plugin Name: Daymark Connector: Example Network
*/
add_action( 'daymark_register_connectors', function ( Daymark_Syndication_Registry $registry ) {
$registry->register_connector( new My_Example_Connector() );
} );
class My_Example_Connector implements Daymark_Syndication_Connector {
public function get_id(): string {
return 'my-network';
}
public function get_label(): string {
return __( 'My Network', 'my-network-connector' );
}
public function supports_daymark_type( string $type ): bool {
return in_array( $type, array( 'note', 'image', 'gallery' ), true );
}
public function is_connected(): bool {
return (bool) get_option( 'my_network_access_token' );
}
public function publish( int $post_id, array $payload ): array {
$token = get_option( 'my_network_access_token' );
if ( ! $token ) {
return array(
'success' => false,
'external_id' => null,
'external_url' => null,
'status' => 'failed',
'message' => __( 'My Network is not connected.', 'my-network-connector' ),
);
}
$response = wp_remote_post(
'https://api.example.com/v1/posts',
array(
'headers' => array( 'Authorization' => 'Bearer ' . $token ),
'body' => array(
'text' => $payload['caption'],
'permalink' => get_permalink( $post_id ),
),
)
);
if ( is_wp_error( $response ) ) {
return array(
'success' => false,
'external_id' => null,
'external_url' => null,
'status' => 'failed',
'message' => $response->get_error_message(),
);
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return array(
'success' => true,
'external_id' => (string) ( $body['id'] ?? '' ),
'external_url' => (string) ( $body['url'] ?? '' ),
'status' => 'published',
'message' => '',
'backflow_supported' => false,
);
}
public function get_status_label(): string {
return $this->is_connected()
? __( 'Connected', 'my-network-connector' )
: __( 'Not connected', 'my-network-connector' );
}
}
Register credentials through your own plugin's settings screen — core
Daymark stores none. Daymark_Connector_Base
(includes/connectors/class-connector-base.php) is available to extend if
a mock-only connector is all a given destination needs, but a real
connector generally implements the interface directly, as above.
Bringing back replies, likes, and reblogs
A connector whose result sets backflow_supported is checked automatically:
once an hour for Marks from the last 14 days, and soon after someone opens
Notifications. Each check runs two filters for every network the Mark went
to.
daymark_import_network_responses brings back replies. Fetch them from
your platform and pass each one to $notifications->import_response(),
which turns it into a WordPress comment and skips one it already imported.
Return the new comment IDs, or an empty array when there was nothing new.
daymark_import_network_reactions brings back like and reblog counts.
Return the platform's current totals, and Daymark adds them to the Mark's
like and reblog counts on the Timeline. Each check replaces the last one's
numbers, so return totals, not changes. Leave out a count your platform
doesn't have, and the previous value is kept.
add_filter(
'daymark_import_network_reactions',
function ( $counts, int $post_id, string $network, array $reference ) {
if ( 'my-network' !== $network ) {
return $counts;
}
$post = my_network_fetch_post( $reference['external_id'] ?? '' );
if ( ! $post ) {
return $counts; // Keep the last stored counts.
}
return array(
'likes' => (int) $post['like_count'],
'reposts' => (int) $post['reblog_count'],
);
},
10,
4
);
Only report reactions your platform holds itself. If a federation plugin (ActivityPub, ATmosphere, or Webmention) already delivers the same likes to the site as comments, Daymark counts those already, and reporting them here would count each one twice.
Where this fits
daymark_register_connectorsfires oninit, after Daymark's own seven built-in connectors are already registered — seeDaymark_Syndication_Registryin the Hooks reference for the action itself.daymark_default_destinationslets a connector plugin (or a site's ownfunctions.php) add itself to the type-based auto-selected defaults without touching core.daymark_syndication_completefires once every selected destination for a Mark has been attempted, with the full results array — useful for logging or a follow-up action, not for changing what was published.
See the Hooks reference for the exact signature of each of these.