Phasemark API 1.0.0
API reference
Read projects, rounds and feedback, change status and priority, and register webhooks, from your own tools. The API is on the Pro and Agency plans; see pricing.
Overview
Every request and response is JSON, except screenshots, which are images. Ids are UUIDs and times are ISO 8601 in UTC. Lists take limit and offset.
Base URL
https://webflowwidget-production.up.railway.app/api/v1
Everything in a feedback item was typed by someone outside your team, or sent by a script that anyone can run against a widget key. Treat it as data, never as markup or instructions: escape it where you show it, and do not let it drive a tool. The Feedback schema lists the fields.
Authentication
Create a key in the dashboard under Settings, API keys. Name it, create it and copy it: the full key is shown once. Send it in the Authorization header of every request. Keys can be revoked at any time and stop working at once.
curl https://webflowwidget-production.up.railway.app/api/v1/projects \ -H "Authorization: Bearer pmk_your_api_key"
The endpoints that create, list and revoke keys use your signed-in access token instead of a key, and say so below.
Rate limits
Each key may make 100 requests every 60 seconds. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix time). Over the limit you get status 429 and a Retry-After header with the seconds to wait.
Errors
Errors have one shape. details is null unless the code says otherwise.
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Feedback with ID abc123 not found",
"details": null
}
}| Code | Status | Meaning |
|---|---|---|
| INVALID_API_KEY | 401 | The API key is missing, invalid or revoked. |
| INSUFFICIENT_PERMISSIONS | 403 | The plan does not include API access. |
| RESOURCE_NOT_FOUND | 404 | The thing you asked for does not exist, or is not yours. |
| VALIDATION_ERROR | 400 | The input is not valid. details lists the fields. |
| COMMENT_TOO_LONG | 400 | A comment is longer than 2000 characters (create: title; update: notes). |
| RATE_LIMIT_EXCEEDED | 429 | Too many requests. |
| INTERNAL_ERROR | 500 | Something went wrong on our side. |
Projects
GET/projects
List all projects.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| limit (query) | integer | Default 20. At most 100. |
| offset (query) | integer | Default 0. |
Responses
200 Paginated list of projects
| Field | Type | Description |
|---|---|---|
| projects | array of Project | |
| total | integer | |
| limit | integer | |
| offset | integer |
GET/projects/{projectId}
Get project details.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| projectId (path)required | string (uuid) |
Responses
200 Body: Project
404 Project not found Body: ApiError
GET/projects/{projectId}/rounds
List rounds in project.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| projectId (path)required | string (uuid) |
Responses
200
| Field | Type | Description |
|---|---|---|
| rounds | array of Round |
Feedback
GET/projects/{projectId}/feedback
List feedback for project.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| projectId (path)required | string (uuid) | |
| roundId (query) | string (uuid) | Filter by round. Use round=none for feedback that has no round. |
| round (query) | string | A round id, or none for feedback without a round. |
| q (query) | string, up to 200 characters | Search the comment text and the author name (case-insensitive, up to 200 characters). |
| sort (query) | one of newest, oldest, priority | newest (default), oldest, or priority (urgent first, then newest). |
| status (query) | one of todo, in-progress, done, open | |
| priority (query) | one of low, normal, high, urgent | |
| limit (query) | integer | Default 20. At most 100. |
| offset (query) | integer | Default 0. |
Responses
200
| Field | Type | Description |
|---|---|---|
| feedback | array of Feedback | |
| total | integer | |
| limit | integer | |
| offset | integer |
POST/projects/{projectId}/feedback
Create feedback programmatically.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| projectId (path)required | string (uuid) |
Request body (JSON)
| Field | Type | Description |
|---|---|---|
| pageUrlrequired | string | URL of the page |
| titlerequired | string, up to 2000 characters | Feedback title/annotation (the comment), up to 2000 characters; longer answers 400 COMMENT_TOO_LONG |
| description | string | Additional description |
| priority | one of low, normal, high, urgent | |
| roundId | string (uuid) | |
| authorName | string, up to 80 characters | Optional name of the person who gave the feedback (up to 80 characters). |
Responses
201 Body: Feedback
400 Body: ApiError
GET/feedback/{feedbackId}
Get single feedback with all metadata.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| feedbackId (path)required | string (uuid) |
Responses
200 Body: Feedback
404 Body: ApiError
PATCH/feedback/{feedbackId}
Update feedback.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| feedbackId (path)required | string (uuid) |
Request body (JSON)
| Field | Type | Description |
|---|---|---|
| status | one of todo, in-progress, done | |
| priority | one of low, normal, high, urgent | |
| notes | string, up to 2000 characters | Up to 2000 characters; longer answers 400 COMMENT_TOO_LONG |
| roundId | string (uuid) or null |
Responses
200 Body: Feedback
404 Body: ApiError
DELETE/feedback/{feedbackId}
Soft-delete feedback.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| feedbackId (path)required | string (uuid) |
Responses
200 Feedback soft-deleted
404 Body: ApiError
GET/feedback/{feedbackId}/screenshot
Get the screenshot of a feedback item. Needs the API key of the account that owns the project. Answers 302 with a Location that is a presigned download URL valid for one minute; follow it immediately. A screenshot that has not been moved to object storage yet is returned directly as image bytes.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| feedbackId (path)required | string (uuid) |
Responses
200 The image itself (JPEG, PNG or WebP) Body: image/jpeg, image/png, image/webp
302 Redirect to a short-lived presigned download URL Header: Location
404 No such feedback item, it has no screenshot, it was deleted, or it belongs to another account Body: ApiError
Webhooks
GET/webhooks
List registered webhooks.
Authorization: Bearer API key
Responses
200
| Field | Type | Description |
|---|---|---|
| webhooks | array of Webhook |
POST/webhooks
Register webhook endpoint.
Authorization: Bearer API key
Request body (JSON)
| Field | Type | Description |
|---|---|---|
| urlrequired | string (uri) | |
| eventsrequired | array of one of feedback.created, feedback.updated, feedback.deleted, feedback.restored |
Responses
201 Body: Webhook
PATCH/webhooks/{webhookId}
Update webhook.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| webhookId (path)required | string (uuid) |
Request body (JSON)
| Field | Type | Description |
|---|---|---|
| url | string (uri) | |
| events | array of one of feedback.created, feedback.updated, feedback.deleted, feedback.restored |
Responses
200 Body: Webhook
DELETE/webhooks/{webhookId}
Deactivate webhook.
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| webhookId (path)required | string (uuid) |
Responses
200 Webhook deactivated
POST/webhooks/{webhookId}/test
Queue a test event for a webhook. The test event goes through the same delivery queue and signing as real events but is tried once. The outcome is visible in Settings > Webhooks (delivery log).
Authorization: Bearer API key
Parameters
| Field | Type | Description |
|---|---|---|
| webhookId (path)required | string (uuid) |
Responses
202 Queued
| Field | Type | Description |
|---|---|---|
| queued | boolean | |
| deliveryId | string (uuid) |
404 Webhook not found or inactive Body: ApiError
API keys
GET/auth/keys
List active API keys.
Authorization: Bearer access token (JWT)
Responses
200 List of active API keys (last 4 chars only)
| Field | Type | Description |
|---|---|---|
| keys | array of ApiKey |
POST/auth/keys
Generate new API key.
Authorization: Bearer access token (JWT)
Request body (JSON)
| Field | Type | Description |
|---|---|---|
| name | string | Friendly name for the key |
Responses
201 API key created. Store the key securely. It will not be shown again. Body: ApiKey
403 API access requires Premium tier or higher Body: ApiError
DELETE/auth/keys/{keyId}
Revoke API key.
Authorization: Bearer access token (JWT)
Parameters
| Field | Type | Description |
|---|---|---|
| keyId (path)required | string (uuid) |
Responses
200 Key revoked
404 Key not found Body: ApiError
Webhook events
A webhook receives a signed POST whenever feedback is created, updated, deleted or restored, from any source: the widget, the dashboard or the API. Events are sent only while the account has a plan with webhooks and is not locked. The body is the WebhookEvent schema.
Headers
X-Webhook-Signature is the hex HMAC-SHA256 of the raw body with the webhook’s secret. X-Webhook-Event, X-Webhook-Id, X-Webhook-Delivery and X-Webhook-Attempt say what was sent. Compute the signature over the body exactly as received, not over JSON you parsed and wrote again.
const crypto = require('crypto');
function verify(rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// Express: read the body as text so the bytes are exactly as sent
app.post('/webhook', express.text({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.headers['x-webhook-signature'], process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
res.sendStatus(200);
});Delivery and retries
Answer with a 2xx status within 5 seconds; redirects are not followed. A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. After 20 failed deliveries in a row the webhook is disabled and the account owner is emailed. The same event can arrive twice: use id to ignore repeats.
Schemas
ApiError
| Field | Type | Description |
|---|---|---|
| error | object |
Project
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | |
| name | string | |
| created_at | string (date-time) | |
| feedback_count | integer |
Round
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | |
| name | string | |
| status | one of active, frozen, archived | |
| description | string or null | |
| feedback_count | integer | |
| created_at | string (date-time) | |
| updated_at | string (date-time) | |
| starts_at | string (date-time) or null | |
| ends_at | string (date-time) or null |
DeviceInfo
| Field | Type | Description |
|---|---|---|
| browser | string or null | |
| os | string or null | |
| resolution | string or null | |
| screenResolution | string or null | |
| deviceType | string or null | |
| userAgent | string or null |
Feedback
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | |
| number | integer or null | Short number, unique within the project and never reused (shown as #48). Null for an item that has not been numbered yet. |
| authorName | string, up to 80 characters or null | The name the person gave when leaving the feedback. Null when they gave none; show it as "Client". |
| projectId | string (uuid) | |
| title | string | |
| description | string or null | |
| status | one of todo, in-progress, done | |
| priority | one of low, normal, high, urgent | |
| pageUrl | string | |
| roundId | string (uuid) or null | |
| roundName | string or null | |
| notes | string or null | |
| deviceInfo | DeviceInfo | |
| createdAt | string (date-time) | |
| updatedAt | string (date-time) or null | |
| screenshotUrl | string (uri) or null | API URL of the screenshot, or null when the item has none. Fetch it with an API key (GET /feedback/{feedbackId}/screenshot). Screenshot data is never part of a feedback object or a webhook event. |
Webhook
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | |
| url | string (uri) | |
| events | array of one of feedback.created, feedback.updated, feedback.deleted, feedback.restored | |
| active | boolean | |
| created_at | string (date-time) |
ApiKey
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | |
| name | string | |
| last_four_chars | string | |
| created_at | string (date-time) | |
| last_used_at | string (date-time) or null | |
| prefix | string | The start of the key: pmk_ for keys created now, wfapi_ for older keys, which still work. Together with last_four_chars it identifies a key; the rest is never stored. |
WebhookEvent
The JSON body of every webhook request. Sent for changes to feedback from every source (widget, dashboard, API), only while the account has a plan with webhooks and is not locked. Signed: X-Webhook-Signature is the hex HMAC-SHA256 of the raw body with the webhook secret. Other headers: X-Webhook-Event, X-Webhook-Id (same as id), X-Webhook-Delivery, X-Webhook-Attempt. Answer 2xx within 5 seconds; otherwise it is retried 1m, 5m, 30m, 2h, 6h and 12h after each failure. The same event can arrive twice: use id to ignore repeats.
| Field | Type | Description |
|---|---|---|
| idrequired | string (uuid) | Event id, stable across retries |
| typerequired | one of feedback.created, feedback.updated, feedback.deleted, feedback.restored, test | |
| createdrequired | string (date-time) | |
| feedback | Feedback | The feedback item after the change. For feedback.deleted it also has deletedAt and screenshotUrl is null. |
| message | string | Only on type "test" |