Produktly API (1.0.0)
Download OpenAPI specification:Download
The Produktly REST API gives you programmatic access to your changelogs, roadmaps, feedback widgets, NPS widgets, tags, analytics, and identified users. It exposes the same functionality as the MCP server, over plain HTTP.
All endpoints (except GET /openapi.json) require an API key passed as a Bearer token.
- Sign in to Produktly and open Settings → API keys.
- Generate a new key and copy it. The same key works for both REST and MCP.
- Send it on every request:
GET /api/v1/changelogs HTTP/1.1
Host: api.produktly.com
Authorization: Bearer <your-api-key>
Missing or invalid keys return 401 unauthorized. Treat the key like a password — anyone with it can read and write your Produktly data.
List endpoints return an envelope with a data array and pagination metadata:
{
"data": [ { "id": 1, "name": "..." } ],
"pagination": { "total": 42, "limit": 50, "offset": 0 }
}
Single-resource endpoints (e.g. GET /roadmaps/{id}) return the resource directly, no envelope.
List endpoints accept limit (default 50, max 100) and offset (default 0) query parameters. Date filters (startDate, endDate) compose with pagination where supported.
Errors come back in a consistent shape:
{
"error": {
"code": "invalid_tag_names",
"message": "Unknown tag names: foo. Use GET /v1/tags to see available tags.",
"details": { "missing": ["foo"] }
}
}
Common codes:
unauthorized(401) — missing or invalid API keynot_found(404) — resource doesn't exist or doesn't belong to your companyvalidation_error(400) — invalid request parametersrate_limit_exceeded(429) — see Rate limits belowinternal_error(500) — server-side problem; safe to retry
Every API key is limited to 60 requests per minute. The /users endpoints have a separate bucket of 300 requests per minute to support backend syncs. Every response includes:
X-RateLimit-Limit: total allowed in the windowX-RateLimit-Remaining: remaining quotaX-RateLimit-Reset: Unix seconds when the bucket refills
When you exceed the limit you'll get 429 rate_limit_exceeded with a Retry-After header. Back off and retry.
List posts in a changelog
Authorizations:
path Parameters
| changelogId required | integer > 0 |
query Parameters
| limit | integer ( 0 .. 100 ] Default: 50 |
| offset | integer or null >= 0 Default: 0 |
| startDate | string |
| endDate | string |
Responses
Response samples
- 200
- 401
- 404
- 429
{- "data": [
- {
- "id": 0,
- "title": "string",
- "description": "string",
- "date": "2019-08-24T14:15:22Z",
- "active": true,
- "tags": [
- {
- "id": 0,
- "name": "string",
- "backgroundColor": "string",
- "textColor": "string"
}
]
}
], - "pagination": {
- "total": 0,
- "limit": 0,
- "offset": 0
}
}Create a changelog post
Authorizations:
path Parameters
| changelogId required | integer > 0 |
Request Body schema: application/json
| title required | string non-empty |
| description | string |
| date | string |
| active | boolean |
| tagNames | Array of strings |
Responses
Request samples
- Payload
{- "title": "string",
- "description": "string",
- "date": "string",
- "active": true,
- "tagNames": [
- "string"
]
}Response samples
- 201
- 400
- 401
- 404
- 429
{- "id": 0,
- "title": "string",
- "description": "string",
- "date": "2019-08-24T14:15:22Z",
- "active": true,
- "tags": [
- {
- "id": 0,
- "name": "string",
- "backgroundColor": "string",
- "textColor": "string"
}
]
}Update a changelog post
Authorizations:
path Parameters
| changelogId required | integer > 0 |
| postId required | integer > 0 |
Request Body schema: application/json
| title | string |
| description | string |
| date | string |
| active | boolean |
| tagNames | Array of strings |
Responses
Request samples
- Payload
{- "title": "string",
- "description": "string",
- "date": "string",
- "active": true,
- "tagNames": [
- "string"
]
}Response samples
- 200
- 400
- 401
- 404
- 429
{- "id": 0,
- "title": "string",
- "description": "string",
- "date": "2019-08-24T14:15:22Z",
- "active": true,
- "tags": [
- {
- "id": 0,
- "name": "string",
- "backgroundColor": "string",
- "textColor": "string"
}
]
}List feedback responses
Authorizations:
path Parameters
| widgetId required | integer > 0 |
query Parameters
| limit | integer ( 0 .. 100 ] Default: 50 |
| offset | integer or null >= 0 Default: 0 |
| startDate | string |
| endDate | string |
Responses
Response samples
- 200
- 401
- 429
{- "data": [
- {
- "id": 0,
- "type": "string",
- "optionName": "string",
- "emoji": "string",
- "message": "string",
- "email": "string",
- "name": "string",
- "fromUrl": "string",
- "createdAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "total": 0,
- "limit": 0,
- "offset": 0
}
}Response samples
- 200
- 401
- 429
{- "data": [
- {
- "id": 0,
- "name": "string",
- "publicName": "string",
- "active": true,
- "customDomain": "string",
- "publicId": "string",
- "sections": [
- {
- "id": 0,
- "name": "string",
- "items": [
- {
- "id": 0,
- "name": "string",
- "description": "string",
- "votesCount": 0,
- "updatedAt": "2019-08-24T14:15:22Z",
- "tag": {
- "id": 0,
- "name": "string",
- "backgroundColor": "string",
- "textColor": "string"
}
}
]
}
]
}
], - "pagination": {
- "total": 0,
- "limit": 0,
- "offset": 0
}
}Get roadmap with sections and items
Authorizations:
path Parameters
| roadmapId required | integer > 0 |
query Parameters
| startDate | string |
| endDate | string |
| latestCount | integer ( 0 .. 100 ] |
| limit | integer ( 0 .. 100 ] |
| query | string |
| tagNames | Array of strings |
Responses
Response samples
- 200
- 401
- 404
- 429
{- "id": 0,
- "name": "string",
- "publicName": "string",
- "active": true,
- "customDomain": "string",
- "publicId": "string",
- "sections": [
- {
- "id": 0,
- "name": "string",
- "items": [
- {
- "id": 0,
- "name": "string",
- "description": "string",
- "votesCount": 0,
- "updatedAt": "2019-08-24T14:15:22Z",
- "tag": {
- "id": 0,
- "name": "string",
- "backgroundColor": "string",
- "textColor": "string"
}
}
]
}
]
}Get NPS score breakdown
Authorizations:
path Parameters
| widgetId required | integer > 0 |
query Parameters
| startDate | string |
| endDate | string |
Responses
Response samples
- 200
- 401
- 429
{- "npsScore": 0,
- "totalResponses": 0,
- "promoters": {
- "count": 0,
- "percentage": 0
}, - "passives": {
- "count": 0,
- "percentage": 0
}, - "detractors": {
- "count": 0,
- "percentage": 0
}
}List NPS responses
Authorizations:
path Parameters
| widgetId required | integer > 0 |
query Parameters
| limit | integer ( 0 .. 100 ] Default: 50 |
| offset | integer or null >= 0 Default: 0 |
| startDate | string |
| endDate | string |
Responses
Response samples
- 200
- 401
- 429
{- "data": [
- {
- "id": 0,
- "rating": "string",
- "comment": "string",
- "email": "string",
- "name": "string",
- "userId": "string",
- "url": "string",
- "language": "string",
- "createdAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "total": 0,
- "limit": 0,
- "offset": 0
}
}Widget stats by entity type
Authorizations:
path Parameters
| entityType required | string Enum: "tour" "checklist" "smartTip" "announcement" "changelog" "npsWidget" "roadmap" |
query Parameters
| startDate | string |
| endDate | string |
| entityId | integer > 0 |
Responses
Response samples
- 200
- 400
- 401
- 429
{- "entityType": "string",
- "period": {
- "start": "string",
- "end": "string"
}, - "widgets": [
- {
- "entityId": 0,
- "name": "string",
- "archivedAt": "2019-08-24T14:15:22Z",
- "events": {
- "property1": {
- "count": 0,
- "uniqueUsers": 0
}, - "property2": {
- "count": 0,
- "uniqueUsers": 0
}
}, - "totalEvents": 0,
- "totalUniqueUsers": 0
}
]
}Company-wide stats summary
Authorizations:
query Parameters
| startDate | string |
| endDate | string |
Responses
Response samples
- 200
- 400
- 401
- 429
{- "period": {
- "start": "string",
- "end": "string",
- "days": 0,
- "granularity": "string"
}, - "previousPeriod": {
- "start": "string",
- "end": "string"
}, - "features": [
- {
- "type": "string",
- "label": "string",
- "primary": {
- "label": "string",
- "count": 0,
- "previousCount": 0,
- "changePct": 0
}, - "secondary": {
- "label": "string",
- "value": 0,
- "previousValue": 0,
- "changePp": 0
}, - "events": {
- "property1": {
- "count": 0,
- "uniqueUsers": 0,
- "previousCount": 0,
- "previousUniqueUsers": 0
}, - "property2": {
- "count": 0,
- "uniqueUsers": 0,
- "previousCount": 0,
- "previousUniqueUsers": 0
}
}, - "trend": [
- {
- "period": "string",
- "count": 0
}
]
}
]
}Get an identified user
Returns the user's attributes and timestamps. metadata can be null for users identified client-side without attributes.
Authorizations:
path Parameters
| userId required | string non-empty Example: user_123 The user's external ID — the same value you pass to window.Produktly.identifyUser(). URL-encode it. |
Responses
Response samples
- 200
- 400
- 401
- 404
- 429
{- "userId": "user_123",
- "metadata": {
- "plan": "pro",
- "seats": 12
}, - "createdAt": "2026-07-01T09:00:00.000Z",
- "updatedAt": "2026-07-15T09:00:00.000Z"
}Create or replace an identified user
Server-side equivalent of identifyUser() with replace semantics: creates the user if it doesn't exist (201), otherwise replaces its entire metadata object (200). Prefer PATCH for scheduled syncs so you don't clobber attributes set client-side.
Authorizations:
path Parameters
| userId required | string non-empty Example: user_123 The user's external ID — the same value you pass to window.Produktly.identifyUser(). URL-encode it. |
Request Body schema: application/json
required | object User attributes as a JSON object (nested values allowed, max 32 KB serialized). Replaces the entire stored metadata object. | ||
| |||
Responses
Request samples
- Payload
{- "metadata": {
- "plan": "pro",
- "seats": 12
}
}Response samples
- 200
- 201
- 400
- 401
- 429
{- "userId": "user_123",
- "metadata": {
- "plan": "pro",
- "seats": 12
}, - "createdAt": "2026-07-01T09:00:00.000Z",
- "updatedAt": "2026-07-15T09:00:00.000Z"
}Merge attributes into an identified user
Shallow-merges the given attributes into the user's metadata (RFC 7386 style at the top level): keys overwrite, null removes a key, omitted keys are kept. Creates the user if it doesn't exist (201).
Authorizations:
path Parameters
| userId required | string non-empty Example: user_123 The user's external ID — the same value you pass to window.Produktly.identifyUser(). URL-encode it. |
Request Body schema: application/json
required | object Attributes to merge (shallow, top level): keys overwrite, a key set to null is removed, omitted keys are kept, nested objects replace wholesale. Merged result max 32 KB serialized. | ||
| |||
Responses
Request samples
- Payload
{- "metadata": {
- "plan": "enterprise",
- "trialEndsAt": null
}
}Response samples
- 200
- 201
- 400
- 401
- 429
{- "userId": "user_123",
- "metadata": {
- "plan": "enterprise",
- "seats": 12
}, - "createdAt": "2026-07-01T09:00:00.000Z",
- "updatedAt": "2026-07-15T09:00:00.000Z"
}Delete an identified user
Hard-deletes the identified user. Analytics events are kept (they reference the external ID as a plain string).
Authorizations:
path Parameters
| userId required | string non-empty Example: user_123 The user's external ID — the same value you pass to window.Produktly.identifyUser(). URL-encode it. |
Responses
Response samples
- 400
- 401
- 404
- 429
{- "error": {
- "code": "string",
- "message": "string",
- "details": {
- "property1": null,
- "property2": null
}
}
}