Project your end-users as actors.
Every social interaction in Social keys off an `actor` — your end-user's projection into a community. Create one with the id you already use on your side; Social returns its own UUID and you key off that from then on.
1
Upsert an actor
POST /v1/communities/:c/actors is idempotent on (community, external_id). The first call creates; the second with the same external_idreturns the same row with created: false.
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{
"external_id": "user_alice",
"display_name": "Alice Lin",
"avatar_url": "https://i.pravatar.cc/300?img=47",
"metadata": {
"bio": "Brand & product designer based in Lisbon ✦",
"location": "Lisbon, Portugal"
}
}' \
https://social.productcraft.co/v1/communities/<community-uuid>/actors{
"actor": {
"id": "d6ee4836-1645-41b0-a1c3-c7e22ad01934",
"community_id": "4d5f5c9e-b4ee-4401-9275-283feb66c178",
"external_id": "user_alice",
"display_name": "Alice Lin",
"avatar_url": "https://i.pravatar.cc/300?img=47",
"metadata": {
"bio": "Brand & product designer based in Lisbon ✦",
"location": "Lisbon, Portugal"
},
"status": "active",
"created_at": "2026-05-02T19:28:58.294Z",
"updated_at": "2026-05-02T19:28:58.294Z"
},
"created": true
}Importing an existing user base? POST /actors/batch takes up to 200 upserts per call — one HTTP call per 200 users instead of one per user. Each item runs the exact single-upsert path and reports its own result, so one invalid row doesn't fail the batch:
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{
"actors": [
{ "external_id": "user_alice", "display_name": "Alice Lin" },
{ "external_id": "user_bob", "display_name": "Bob Park" }
]
}' \
https://social.productcraft.co/v1/communities/<community-uuid>/actors/batch
# → { "data": [ { "index": 0, "success": true, "actor": {...}, "created": true }, ... ],
# "succeeded": 2, "failed": 0 }2
Idempotency in practice
Repeating the call returns created: false with the same actor.id. The shape is replace-style — fields you omit will be overwritten by the new body, including metadata. Always include every field you want to keep on each upsert call.
3
List actors
Cursor-paginated, newest first. Default page size 50 (max 200). Pass ?cursor=<next_cursor> while has_more is true:
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<community-uuid>/actors?limit=10"{
"data": [
{ "id": "76054baa-...", "external_id": "user_carol", "display_name": "Carol Diaz", ... },
{ "id": "a54d4499-...", "external_id": "user_bob", "display_name": "Bob Park", ... },
{ "id": "d6ee4836-...", "external_id": "user_alice", "display_name": "Alice Lin", ... }
],
"pagination": { "next_cursor": null, "has_more": false }
}4
Resolve an external id → actor
On the wire, every Social call below this stage takes an social actor.id (a UUID). When you only have your customer-side external_id and need to resolve it, use the lookup helper:
curl -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<community-uuid>/actors/by-external/user_carol
# returns the actor row, or 404Resolving many at once (a migration backfill, a sync job)? POST /actors/by-external/batch takes { "external_ids": [...] } — up to 200 per call, POST because external_ids can be 256 chars — and returns the actors found plus a missing[] array for ids with no actor yet. The UUID mirror is GET /actors?ids=<uuid,uuid,…> (max 100).
5
Edit profile fields
PATCH /actors/:actorId takes a partial body and updates only the fields present:
curl -X PATCH -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{
"metadata": {
"bio": "Travel photographer. Currently: Oaxaca.",
"location": "Oaxaca, MX"
}
}' \
https://social.productcraft.co/v1/communities/<community-uuid>/actors/<actor-uuid>Mental model: in your app, an end-user has a row in your users table. Mirror that into Social once at signup time — just call POST /actorswith the user's id as external_id. From then on, your backend keeps user.id ↔ actor.id mapped however you like (a column on your users table works fine).
Store the renderable profile — display name, avatar, a bio in metadata — on the actor and keep it fresh on profile edits. That's what makes the rest of the API self-sufficient: followers / following / mutes / blocks lists take ?expand=<field> (see stage 5) and return these fields nested on each edge, so social screens render from one Social call with no join back to your own tables. The id mapping is for writes (acting as a user); reads shouldn't need it.