API
READ
WRITE
Authentication
Check your API key
You can check it from the menu in the top right > Settings > Account > API Keys.
Add it to the request header
Add your issued API key to the Authorization header in Bearer format.
Send a request to the base URL
Send requests to the base URL below followed by the endpoint path.
Each account has one API key. When you change the key, the old key stops working immediately. Keep your key private.
Base URL
https://stepeee.com/public-api/v1
Request example
curl "https://stepeee.com/public-api/v1/projects" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
Response format & errors
All responses are returned in the following envelope format.
On success
{ "data": ... }
On error
{ "error": { "code": "...", "message": "...", "details": { ... } } }
Status codes
| Status | Meaning |
|---|---|
| 401 | The API key is missing, invalid, or has been revoked |
| 403 | You do not have permission for this request (for example, calling an endpoint that requires the Pro plan or higher with an API key of an account below the Pro plan, or a team staff member requesting the team's token usage) |
| 404 | The resource does not exist (this also includes cases where you do not have access permission) |
| 405 | The HTTP method cannot be used for this path |
| 409 | This action cannot be performed in the current state (for example, deleting content whose automation is running, or a request with the same Idempotency-Key still being processed). Use error.code to tell the reason |
| 422 | The parameters are invalid or the request cannot be accepted. For validation_error, error.details contains the reason for each parameter |
| 429 | The rate limit has been exceeded |
| 500 | An unexpected error occurred |
| 503 | The action could not be started (for example, when starting the automation run failed). Please try again later |
Conventions
What you can access and change
You can read and change the same things the API key owner can see in the app. In addition to your own projects, on the Team plan the team owner and admins can read and change all of the team's projects, and staff can read and change the projects they have been granted. Resources outside this scope return 404, the same as resources that do not exist.
Deleted data
Deleted projects, concepts, titles, content, and materials are not returned, including during the period before they are permanently removed. When you delete a title or concept, the content under it is not returned either.
Pagination
Lists use page (starting at 1) and per_page (1–100, default 20) to choose the range to return. meta contains the total count (total), the current page (current_page), the number of items per page (per_page), and the last page (last_page).
Dates and times
All dates and times in responses are in UTC ISO 8601 format (for example, 2026-10-21T09:30:00Z). The from / to parameters accept a date (2026-10-21), a date and time (2026-10-21T09:30:00), or a date and time with an offset (2026-10-21T18:30:00+09:00); values without an offset are treated as UTC. from includes that moment, and when you pass only a date to to, the whole day up to 23:59:59 is included.
IDs
Resources are identified by alphanumeric public IDs such as project_unique and variation_unique. IDs do not change.
File URLs
URLs for images, videos, and audio are returned as {url, expires_at}. Private files use signed URLs that stop working after expires_at (one hour after they are issued). To keep using a file, download and store it before it expires, or call the API again to get a new URL. Files set to public use URLs that do not expire, and expires_at is null.
Value types
Values such as status and platform are returned as the lowercase strings listed on this page. New values may be added as features are added, so make sure your code keeps working when it receives a value it does not know.
Amounts
Amounts are integers in the smallest unit of the currency (cents for USD, yen for JPY). The currency is in currency as a lowercase ISO 4217 code (usd, jpy, and so on).
Write requests (Pro plan)
With methods other than GET, you can perform actions such as adding titles, deleting content, starting automations, approving posts, and registering webhooks. Each action runs the same process as the same action in the app, and the same rules apply. Write requests are available only when the API key owner is on the Pro plan or higher; below the Pro plan they return 403 plan_required. Team members get 403 team_plan_lost when the team owner is no longer on the Team plan. However, stopping automations, listing and deleting webhooks, and listing posts awaiting review are available on any plan (so you can stop running automations and notifications even after changing your plan). Creating something returns 201; everything else returns 200.
Idempotency-Key
So that you can safely retry an action when the connection drops and you do not receive the response, some write endpoints accept the Idempotency-Key header (the endpoints that accept it list it in their parameter table; it is required for starting automations). Use a unique string that you generate for each action (such as a UUID; 1–255 printable ASCII characters). If you resend the same content (method, path, and body) with the same key, the action is not performed again; the first response is returned as is, with the Idempotent-Replayed: true header. Keys are valid for 24 hours per API key owner. Sending different content with the same key returns 422 idempotency_key_reused, and if the first request is still being processed, 409 idempotency_request_in_progress is returned. 5xx responses are not stored, so you can retry with the same key. On endpoints where the header is required, a missing header returns 422 idempotency_key_required, and an invalid format returns 422 idempotency_key_invalid.
Rate limit
You can send up to 120 requests per minute per API key (write requests also have the write rate limit described below). You can check X-RateLimit-Limit (the limit) and X-RateLimit-Remaining (the remaining count) in the response headers. When you exceed the limit, 429 is returned, and Retry-After contains the number of seconds until you can retry.
Write rate limit
Write requests (methods other than GET) are limited to 30 requests per minute per API key, in addition to the 120 requests per minute limit. When you exceed it, 429 rate_limited is returned, and Retry-After contains the number of seconds until you can retry. For write requests, the X-RateLimit-Limit and X-RateLimit-Remaining response headers show the values of whichever of the two limits has fewer requests remaining.
Error codes
On error, error.code contains the type of error and error.message contains a description in English. The codes that any endpoint can return are listed below. Codes specific to an endpoint (such as title_limit_reached) are listed in that endpoint's error table. Use error.code in your code to tell errors apart.
| Code | Meaning |
|---|---|
unauthorized | The API key is missing, invalid, or has been revoked (401) |
forbidden | You do not have permission (403) |
plan_required | The action requires the Pro plan or higher (403) |
team_plan_lost | The team owner is no longer on the Team plan, so team members cannot write (403) |
not_found | The resource does not exist or is outside what you can access (404) |
method_not_allowed | The HTTP method cannot be used for this path (405) |
conflict | This action cannot be performed in the current state (409) |
idempotency_request_in_progress | A request with the same Idempotency-Key is still being processed (409) |
validation_error | The parameters are invalid (422) |
idempotency_key_required | The Idempotency-Key header is missing on an endpoint where it is required (422) |
idempotency_key_invalid | The Idempotency-Key is not 1–255 printable ASCII characters (422) |
idempotency_key_reused | The same Idempotency-Key was used for a request with different content (422) |
rate_limited | The rate limit was exceeded (429) |
internal_error | An unexpected error occurred (500) |
422 example
{
"error": {
"code": "validation_error",
"message": "The per page field must not be greater than 100.",
"details": {
"per_page": ["The per page field must not be greater than 100."]
}
}
}
Account
Information about the API key owner's account. You can get the plan, token balance, usage, storage, payments, and referral program status.
-
Account information
GET /me -
Subscription
GET /me/subscription -
Token balance
GET /me/tokens -
Token usage
GET /me/token-usage -
Storage
GET /me/storage -
Payment history
GET /me/payments -
Referral program
GET /me/referral
Account information
Gets the API key owner's profile, plan, plan limits, and team.
| Field | Type | Description |
|---|---|---|
user_unique |
string | The user ID |
nickname |
string | null | Nickname |
email |
string | The email address used to sign in |
plan |
string | The plan. free / vault / plus / pro / team |
limits |
object | The limits of the current plan. null means unlimited |
limits.projects |
integer | null | The number of projects you can create |
limits.moviemode_variations_per_title |
integer | null | The number of content items per video title |
limits.blogmode_variations_per_title |
integer | null | The number of content items per blog title |
limits.carouselmode_variations_per_title |
integer | null | The number of content items per carousel title |
limits.postmode_variations_per_concept |
integer | null | The number of content items per post concept |
limits.team |
boolean | Whether team features are available |
limits.script_duration_max |
integer | The maximum length of a video script (seconds) |
limits.reading_time_max |
integer | The maximum reading time of a blog article (minutes) |
limits.reading_time_max_fulltext |
integer | The maximum reading time of a blog article when the full text is read aloud (minutes) |
limits.slide_count_max |
integer | The maximum number of carousel slides |
limits.cta_enabled |
boolean | Whether CTAs (such as a final slide that prompts action) are available |
limits.thread_enabled |
boolean | Whether threads and posts with images are available for posts |
limits.schedule_title_variation_max |
integer | The number of titles and content items you can specify at once when creating scheduled items |
limits.schedule_variation_create_max |
integer | The number of content items you can create at once when creating scheduled items |
language |
string | null | The display language of the app |
timezone |
string | Your time zone setting (IANA format, for example Asia/Tokyo) |
team |
object | null | The team you belong to. null if you are not on the Team plan or do not belong to a team |
team.team_unique |
string | The team ID |
team.role |
string | Your role in the team. owner / admin / staff |
created_at |
string | When the account was created |
curl "https://stepeee.com/public-api/v1/me" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/me",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/me", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/me")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/me"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/me");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"user_unique": "aB3dE5gH7j",
"nickname": "Alice",
"email": "alice@example.com",
"plan": "pro",
"limits": {
"projects": 3,
"moviemode_variations_per_title": null,
"blogmode_variations_per_title": null,
"carouselmode_variations_per_title": null,
"postmode_variations_per_concept": null,
"team": false,
"script_duration_max": 1800,
"reading_time_max": 15,
"reading_time_max_fulltext": 4,
"slide_count_max": 20,
"cta_enabled": true,
"thread_enabled": true,
"schedule_title_variation_max": 50,
"schedule_variation_create_max": 5
},
"language": "ja",
"timezone": "Asia/Tokyo",
"team": null,
"created_at": "2026-09-01T10:20:30Z"
}
}
Subscription
Gets the status of your current plan, the billing period, any scheduled cancellation, and any scheduled plan change. Team members get the team owner's subscription.
| Field | Type | Description |
|---|---|---|
plan |
string | The plan. free / vault / plus / pro / team |
status |
string | The subscription status. active / past_due (payment overdue) / trialing (in a trial) / none (no paid plan) |
managed_by_team |
boolean | Whether you use the plan through the team owner's subscription. When true, the period and cancellation fields are the owner's values |
current_period_start |
string | null | When the current billing period started |
current_period_end |
string | null | When the current billing period ends (the next billing date) |
cancel_at_period_end |
boolean | Whether the subscription is scheduled to cancel at the end of the current billing period |
cancel_at |
string | null | When the subscription will be canceled |
canceled_at |
string | null | When the cancellation was requested |
scheduled_change |
object | null | A scheduled plan change (null if none) |
scheduled_change.plan |
string | The plan after the change |
scheduled_change.effective_at |
string | null | When the change takes effect |
curl "https://stepeee.com/public-api/v1/me/subscription" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/me/subscription",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/me/subscription", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/me/subscription")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/me/subscription"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/me/subscription");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"plan": "pro",
"status": "active",
"managed_by_team": false,
"current_period_start": "2026-10-01T00:00:00Z",
"current_period_end": "2026-11-01T00:00:00Z",
"cancel_at_period_end": false,
"cancel_at": null,
"canceled_at": null,
"scheduled_change": {
"plan": "plus",
"effective_at": "2026-11-01T00:00:00Z"
}
}
}
Token balance
Gets your available token balance. On a team, it returns the balance shared by the team and the quota assigned to you.
| Field | Type | Description |
|---|---|---|
balance |
integer | Balance |
reserved |
integer | The amount held for jobs that are running |
available |
integer | The amount you can use now (the balance minus the held amount) |
wallet |
string | personal (your own balance) / team (the balance shared by the team) |
team_quota |
object | null | Your quota set by the team owner. null when you use your own balance and for the team owner |
team_quota.unlimited |
boolean | Whether the quota is unlimited |
team_quota.view_only |
boolean | Whether you can only view and cannot use tokens |
team_quota.budget |
integer | null | The limit per period (null means unlimited, 0 means view only) |
team_quota.used |
integer | The amount used in the current period |
team_quota.available |
integer | null | The amount left in the current period (null if unlimited) |
team_quota.period |
string | null | The limit period. day / week / month (null when there is no limit) |
team_quota.next_reset_at |
string | null | When usage resets |
auto_purchase.enabled |
boolean | Whether automatic purchase when the balance runs low is on |
auto_purchase.plan |
string | null | The token package bought automatically (for example, token_100). null when off |
auto_purchase.threshold |
integer | A purchase is made automatically when the balance falls below this value |
curl "https://stepeee.com/public-api/v1/me/tokens" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/me/tokens",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/me/tokens", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/me/tokens")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/me/tokens"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/me/tokens");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"balance": 90000,
"reserved": 1200,
"available": 88800,
"wallet": "team",
"team_quota": {
"unlimited": false,
"view_only": false,
"budget": 10000,
"used": 2500,
"available": 7500,
"period": "week",
"next_reset_at": "2026-10-26T00:00:00Z"
},
"auto_purchase": {
"enabled": true,
"plan": "token_100",
"threshold": 5000
}
}
}
Token usage
Gets token usage and additions by day or by month. Usage is totaled one day at a time, and the previous day is reflected around 2:45 UTC (today's usage is not included). Additions from purchases and bonuses are reflected immediately.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
from |
Query | string (YYYY-MM-DD) | Optional | — | The start date (UTC). 29 days before to when omitted |
to |
Query | string (YYYY-MM-DD) | Optional | — | The end date (UTC, inclusive). Today when omitted. The range can be up to 366 days |
group_by |
Query | string | Optional | day |
How to group. day / month |
| Field | Type | Description |
|---|---|---|
data[].period |
string | The period. YYYY-MM-DD by day, YYYY-MM by month. Periods with no usage or additions are not included |
data[].consumed |
integer | Usage |
data[].added |
integer | Additions (net of decreases such as revoked referral rewards) |
data[].consumed_by_type |
object | Usage by type (text / image / video / audio, and so on) |
data[].added_by_type |
object | Additions by type. purchase / bonus / refund / referral_revoke (revoked referral reward, negative) |
meta.from |
string | The start date of the totaled period |
meta.to |
string | The end date of the totaled period |
meta.group_by |
string | How the results are grouped |
meta.total_consumed |
integer | Total usage in the period |
meta.total_added |
integer | Total additions in the period |
| Status | Code | When |
|---|---|---|
| 403 | forbidden |
A staff member who uses the team's tokens (only the team owner and admins can get the team's token usage) |
| 422 | validation_error |
The range is longer than 366 days, to is earlier than from, a value cannot be read as a date, or group_by is not day / month |
curl "https://stepeee.com/public-api/v1/me/token-usage?from=2026-10-01&to=2026-10-20&group_by=day" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/me/token-usage?from=2026-10-01&to=2026-10-20&group_by=day",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/me/token-usage?from=2026-10-01&to=2026-10-20&group_by=day", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/me/token-usage?from=2026-10-01&to=2026-10-20&group_by=day")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/me/token-usage?from=2026-10-01&to=2026-10-20&group_by=day"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/me/token-usage?from=2026-10-01&to=2026-10-20&group_by=day");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"period": "2026-10-18",
"consumed": 450,
"added": 0,
"consumed_by_type": { "image": 300, "text": 150 },
"added_by_type": {}
},
{
"period": "2026-10-20",
"consumed": 1000,
"added": 4800,
"consumed_by_type": { "video": 1000 },
"added_by_type": { "purchase": 5000, "referral_revoke": -200 }
}
],
"meta": {
"from": "2026-10-01",
"to": "2026-10-20",
"group_by": "day",
"total_consumed": 1450,
"total_added": 4800
}
}
Storage
Gets storage usage, capacity, and a breakdown by file type. On a team, it returns the storage shared by the team.
| Field | Type | Description |
|---|---|---|
used_bytes |
integer | Usage (bytes). The same as the total of by_type |
capacity_bytes |
integer | null | Capacity (bytes). null means unlimited, and 0 means no storage (Free plan) |
by_type[].file_type |
string | null | The file type. material / image / video / bgm / voice (audio) / movie (finished video) |
by_type[].bytes |
integer | Usage of that type (bytes) |
by_type[].files |
integer | The number of files of that type |
curl "https://stepeee.com/public-api/v1/me/storage" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/me/storage",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/me/storage", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/me/storage")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/me/storage"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/me/storage");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"used_bytes": 734003200,
"capacity_bytes": null,
"by_type": [
{ "file_type": "image", "bytes": 52428800, "files": 120 },
{ "file_type": "movie", "bytes": 629145600, "files": 8 },
{ "file_type": "voice", "bytes": 52428800, "files": 15 }
]
}
}
Payment history
Gets payments such as plan fees and token purchases, newest first. You can use this to import payments into your accounting system.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
status |
Query | string | Optional | — | Filter by payment status (comma-separated). succeeded / refunded / partially_refunded. All when omitted |
type |
Query | string | Optional | — | Filter by payment type (comma-separated). The values are the same as data[].type |
from |
Query | string | Optional | — | Returns only items created at or after this date and time |
to |
Query | string | Optional | — | Returns only items created at or before this date and time |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].payment_unique |
string | The payment ID |
data[].type |
string | The type. token_first_time (first token purchase) / token_regular (token purchase) / subscription_vault, subscription_plus, subscription_pro, subscription_team (plan fees) / smm_application, smm_contract, smm_usage (X account management fees) |
data[].description |
string | The name of the payment (in English) |
data[].status |
string | The status. succeeded (paid) / refunded (fully refunded) / partially_refunded |
data[].currency |
string | The currency (lowercase ISO 4217) |
data[].amount_subtotal |
integer | The amount before tax (smallest unit) |
data[].tax_amount |
integer | The tax (smallest unit) |
data[].amount_total |
integer | The amount including tax (smallest unit) |
data[].line_items[] |
object | Line items. type (base_plan: plan fee / storage_addon: additional storage / extra_member: additional member), quantity, amount, and description (null if none) |
data[].receipt_url |
string | null | The receipt URL (Stripe). It may stop opening some time after it is issued |
data[].invoice_pdf_url |
string | null | The invoice PDF URL (Stripe). It may stop opening some time after it is issued |
data[].created_at |
string | When the payment was made |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/me/payments?status=succeeded&per_page=10" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/me/payments?status=succeeded&per_page=10",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/me/payments?status=succeeded&per_page=10", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/me/payments?status=succeeded&per_page=10")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/me/payments?status=succeeded&per_page=10"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/me/payments?status=succeeded&per_page=10");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"payment_unique": "Xy12AbCd34Ef",
"type": "subscription_team",
"description": "Team Monthly",
"status": "succeeded",
"currency": "usd",
"amount_subtotal": 5000,
"tax_amount": 500,
"amount_total": 5500,
"line_items": [
{ "type": "base_plan", "quantity": 1, "amount": 4000, "description": "Team plan" },
{ "type": "extra_member", "quantity": 2, "amount": 1000, "description": null }
],
"receipt_url": "https://pay.stripe.com/receipts/...",
"invoice_pdf_url": "https://pay.stripe.com/invoice/.../pdf",
"created_at": "2026-10-01T08:00:00Z"
}
],
"meta": { "total": 1, "current_page": 1, "per_page": 10, "last_page": 1 }
}
Referral program
Gets your referral code and the status of your referrals and rewards.
| Field | Type | Description |
|---|---|---|
code |
string | null | The referral code (null if you do not have one yet) |
referral_url |
string | null | The referral URL (null if you do not have a referral code) |
is_active |
boolean | Whether the referral code is active |
reward_type |
string | null | How you receive rewards. cash / token. null if you do not have a referral code |
clicks |
integer | The number of times the referral URL was opened |
signups |
integer | The number of people who signed up through your referral |
conversions |
integer | The number of people who subscribed to a paid plan through your referral |
earned |
object | Rewards confirmed so far {amount (smallest unit), currency} |
earned_tokens |
integer | Tokens received so far |
pending |
object | Rewards waiting to be confirmed {amount, currency} |
available |
object | Rewards you can request to receive {amount, currency} |
curl "https://stepeee.com/public-api/v1/me/referral" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/me/referral",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/me/referral", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/me/referral")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/me/referral"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/me/referral");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"code": "REFk5m2j8x9ab",
"referral_url": "https://stepeee.com?ref=REFk5m2j8x9ab",
"is_active": true,
"reward_type": "cash",
"clicks": 12,
"signups": 4,
"conversions": 2,
"earned": { "amount": 1300, "currency": "usd" },
"earned_tokens": 0,
"pending": { "amount": 300, "currency": "usd" },
"available": { "amount": 800, "currency": "usd" }
}
}
Projects
Get projects and the structure of concepts and titles inside them.
-
Project list
GET /projects -
Project detail
GET /projects/{project_unique} -
Concept list
GET /projects/{project_unique}/concepts -
Concept detail
GET /projects/{project_unique}/concepts/{concept_unique}
Project list
Gets the projects you can access, newest first.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].project_unique |
string | The project ID |
data[].project_name |
string | The project name |
data[].description |
string | null | The project description |
data[].access_level |
string | Your permission. owner (your own project) / admin (full permission as the team owner or an admin) / edit (edit permission granted in the team) |
data[].team |
object | null | The team for a team project, or null for a project that is only yours |
data[].team.team_unique |
string | The team ID |
data[].is_readonly |
boolean | Whether the project is view-only because it exceeds the limit after a plan downgrade (this is removed when you upgrade) |
data[].readonly_since |
string | null | When the project became view-only |
data[].created_at |
string | Created at |
data[].updated_at |
string | Updated at |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/projects" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"project_unique": "a1b2c3d4e5",
"project_name": "Sample Project",
"description": "Sample project description",
"access_level": "owner",
"team": null,
"is_readonly": false,
"readonly_since": null,
"created_at": "2026-01-10T09:00:00Z",
"updated_at": "2026-10-01T12:00:00Z"
}
],
"meta": { "total": 1, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Project detail
Gets the details of one project, with the number of concepts and titles per mode and the number of content items by progress.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
| Field | Type | Description |
|---|---|---|
project_unique |
string | The project ID |
project_name |
string | The project name |
description |
string | null | The project description |
access_level |
string | Your permission. owner (your own project) / admin (full permission as the team owner or an admin) / edit (edit permission granted in the team) |
team |
object | null | The team for a team project, or null for a project that is only yours |
team.team_unique |
string | The team ID |
is_readonly |
boolean | Whether the project is view-only because it exceeds the limit after a plan downgrade (this is removed when you upgrade) |
readonly_since |
string | null | When the project became view-only |
created_at |
string | Created at |
updated_at |
string | Updated at |
scope |
string | The target region. global (not specified) / region (a specific country) |
scope_region |
string | null | The target country (ISO 3166-1 alpha-2, for example JP). null when global |
counts.{mode}.concepts |
integer | The number of concepts per mode (moviemode / blogmode / carouselmode / postmode) |
counts.{mode}.titles |
integer | null | The number of titles (null for postmode, which has no titles) |
counts.{mode}.contents |
object | The number of content items {total, not_started, in_progress, complete} |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"project_unique": "a1b2c3d4e5",
"project_name": "Sample Project",
"description": "Sample project description",
"access_level": "owner",
"team": null,
"is_readonly": false,
"readonly_since": null,
"created_at": "2026-01-10T09:00:00Z",
"updated_at": "2026-10-01T12:00:00Z",
"scope": "region",
"scope_region": "JP",
"counts": {
"moviemode": {
"concepts": 2,
"titles": 5,
"contents": { "total": 8, "not_started": 1, "in_progress": 3, "complete": 4 }
},
"blogmode": {
"concepts": 1,
"titles": 3,
"contents": { "total": 3, "not_started": 0, "in_progress": 1, "complete": 2 }
},
"carouselmode": {
"concepts": 0,
"titles": 0,
"contents": { "total": 0, "not_started": 0, "in_progress": 0, "complete": 0 }
},
"postmode": {
"concepts": 1,
"titles": null,
"contents": { "total": 1, "not_started": 0, "in_progress": 1, "complete": 0 }
}
}
}
}
Concept list
Gets the concepts in a project across all modes, newest first.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
mode |
Query | string | Optional | — | Filter by mode. You can pass several of moviemode (video), blogmode (blog), carouselmode (carousel), and postmode (post), separated by commas. All modes when omitted |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].concept_unique |
string | The concept ID |
data[].mode |
string | moviemode (video) / blogmode (blog) / carouselmode (carousel) / postmode (post) |
data[].concept_name |
string | The concept name |
data[].title_count |
integer | null | The number of titles (null for postmode) |
data[].content_count |
integer | The number of content items |
data[].created_at |
string | Created at |
data[].updated_at |
string | Updated at |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts?mode=moviemode,postmode" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts?mode=moviemode,postmode",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts?mode=moviemode,postmode", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts?mode=moviemode,postmode")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts?mode=moviemode,postmode"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts?mode=moviemode,postmode");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"concept_unique": "pC7d2KqLx01m",
"mode": "postmode",
"concept_name": "SaaS growth tips",
"title_count": null,
"content_count": 1,
"created_at": "2026-10-02T09:00:00Z",
"updated_at": "2026-10-02T09:00:00Z"
},
{
"concept_unique": "mC4f9TnB2q",
"mode": "moviemode",
"concept_name": "Cat facts",
"title_count": 3,
"content_count": 5,
"created_at": "2026-09-20T03:00:00Z",
"updated_at": "2026-09-28T03:00:00Z"
}
],
"meta": { "total": 2, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Concept detail
Gets the details of one concept. Video, blog, and carousel concepts include the list of titles, and post (postmode) concepts include the persona of the poster and the posting rules.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
concept_unique |
Path | string | Required | — | The concept ID |
| Field | Type | Description |
|---|---|---|
concept_unique / mode / concept_name / title_count / content_count / created_at / updated_at |
— | Same as the concept list |
description |
string | null | The concept description |
scope |
string | The target region. global (not specified) / region (a specific country) |
scope_region |
string | null | The target country (ISO 3166-1 alpha-2, for example JP). null when global |
default_template_unique |
string | null | The setup template used for new content (template_unique in the template list). null when not set or for postmode |
titles |
array | null | The list of titles (newest first). null for postmode |
titles[].title_unique |
string | The title ID |
titles[].title_name |
string | The title |
titles[].article_type |
string | null | The type. standard / news for moviemode, standard / news / promotion (product introduction) for blogmode. null for carouselmode |
titles[].content_count |
integer | The number of content items for that title |
persona |
object | null | The persona of the poster (postmode only, null if not created) |
persona.persona_unique |
string | The persona ID |
persona.gender |
string | Gender. male / female / undisclosed |
persona.generation |
string | Age group. 10s / 20s / 30s / 40s / 50s / 60s_plus |
persona.region |
string | null | The country code of the cultural region |
persona.keywords |
object | Persona keywords. occupation, industry, residence, workstyle, household, and personality are strings, and interest and expertise are arrays of strings. Only the ones that are set are included |
persona.description |
string | The persona text |
persona.casual_description |
string | null | The persona text used for everyday posts (null if none) |
persona.taboos |
array | Things to avoid in posts |
persona.timezone_mode |
string | How the time zone is decided. auto (from the region, or from the posting language when there is no region) / specify (the specified time zone) |
persona.timezone |
string | null | The time zone used for the posting schedule (IANA format). null when it cannot be decided because the posting rules have not been created |
rule |
object | null | The posting rules (postmode only, null if not created) |
rule.rule_unique |
string | The posting rules ID |
rule.language |
string | The posting language |
rule.formality |
string | Tone. very_casual / casual / normal / polite / honorific |
rule.emoji |
string | Emoji. never / sometimes / often / always |
rule.line_break |
string | How many line breaks. none / moderate / frequent |
rule.post_type_share |
object | The share of each post type (%). assertion (opinion) / knowhow / experience / casual (everyday) |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 404 | not_found |
The concept does not exist (Concept not found.) |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts/pC7d2KqLx01m" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts/pC7d2KqLx01m",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts/pC7d2KqLx01m", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts/pC7d2KqLx01m")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts/pC7d2KqLx01m"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/concepts/pC7d2KqLx01m");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"concept_unique": "pC7d2KqLx01m",
"mode": "postmode",
"concept_name": "SaaS growth tips",
"title_count": null,
"content_count": 1,
"created_at": "2026-10-02T09:00:00Z",
"updated_at": "2026-10-02T09:00:00Z",
"description": "Practical growth tips for SaaS founders",
"scope": "global",
"scope_region": null,
"default_template_unique": null,
"titles": null,
"persona": {
"persona_unique": "aV3kQ9mN2pRt",
"gender": "female",
"generation": "30s",
"region": "JP",
"keywords": { "occupation": "SaaS marketer", "interest": ["growth", "analytics"] },
"description": "A B2B SaaS marketer with ten years of experience...",
"casual_description": null,
"taboos": ["politics"],
"timezone_mode": "auto",
"timezone": "Asia/Tokyo",
"updated_at": "2026-10-02T09:10:00Z"
},
"rule": {
"rule_unique": "rU5tW7yZ9aBc",
"language": "ja",
"formality": "polite",
"emoji": "sometimes",
"line_break": "moderate",
"post_type_share": { "assertion": 40, "knowhow": 30, "experience": 20, "casual": 10 },
"updated_at": "2026-10-02T09:20:00Z"
}
}
}
Content
Get video, blog, carousel, and post content across modes. In the detail, package contains what is posted to each social network or site (body text, images, videos, and captions), so you can bring the content you made in Stepeee into your own systems.
-
Content list
GET /projects/{project_unique}/contents -
Content detail
GET /projects/{project_unique}/contents/{variation_unique} -
Posting schedule
GET /projects/{project_unique}/contents/{variation_unique}/schedule
Content list
Gets the content in a project (video, blog, carousel, and post) across modes. You can filter by progress, publishing language, whether it has been posted, and more.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
mode |
Query | string | Optional | — | Filter by mode. You can pass several of moviemode (video), blogmode (blog), carouselmode (carousel), and postmode (post), separated by commas. All modes when omitted |
status |
Query | string | Optional | — | Filter by progress. not_started / in_progress / complete |
concept_unique |
Query | string | Optional | — | Returns only the content of this concept |
title_unique |
Query | string | Optional | — | Returns only the content of this title (postmode is not included because it has no titles) |
language |
Query | string | Optional | — | Filter by publishing language (for example, ja or en) |
published |
Query | string | Optional | — | true returns only content that has been posted, and false returns only content that has not been posted yet |
from |
Query | string | Optional | — | Returns only items created at or after this date and time |
to |
Query | string | Optional | — | Returns only items created at or before this date and time |
sort |
Query | string | Optional | -created_at |
The order. created_at / -created_at / updated_at / -updated_at (- means newest first) |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].mode |
string | moviemode (video) / blogmode (blog) / carouselmode (carousel) / postmode (post) |
data[].variation_unique |
string | The content ID |
data[].variation_no |
integer | The number within the title (the concept for postmode) |
data[].title |
string | The title (the concept name for postmode) |
data[].concept |
object | The concept it belongs to {concept_unique, concept_name} |
data[].title_unique |
string | null | The ID of the title it belongs to (null for postmode) |
data[].language |
string | null | The publishing language (the language viewers and readers see). null if not set |
data[].status |
string | Progress. not_started / in_progress / complete (marked as complete) |
data[].progress.step_process |
integer | The completed step (0–3) |
data[].progress.is_complete |
boolean | Whether it is marked as complete |
data[].is_readonly |
boolean | Whether it is view-only because of a plan downgrade |
data[].confirmed_at |
string | null | When the content was marked as reviewed (null for postmode and when not reviewed) |
data[].published_platforms |
array | The destinations where it has been posted (success / partial_success) |
data[].created_at |
string | Created at |
data[].updated_at |
string | Updated at |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents?mode=blogmode&status=complete&published=false" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents?mode=blogmode&status=complete&published=false",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents?mode=blogmode&status=complete&published=false", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents?mode=blogmode&status=complete&published=false")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents?mode=blogmode&status=complete&published=false"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents?mode=blogmode&status=complete&published=false");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"mode": "blogmode",
"variation_unique": "bmA1b2C3d4E5",
"variation_no": 1,
"title": "How to start a blog",
"concept": { "concept_unique": "bC2x8RtY4u", "concept_name": "Blogging for beginners" },
"title_unique": "bT9kL3mN5pQr",
"language": "en",
"status": "complete",
"progress": { "step_process": 3, "is_complete": true },
"is_readonly": false,
"confirmed_at": "2026-10-03T08:00:00Z",
"published_platforms": [],
"created_at": "2026-10-01T03:00:00Z",
"updated_at": "2026-10-03T08:00:00Z"
}
],
"meta": { "total": 1, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Content detail
Gets the details of one content item. package contains what is actually posted to each destination (the same as when posting from Stepeee). For post (postmode) content, get the list of posts with the schedule.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
variation_unique |
Path | string | Required | — | The content ID |
| Field | Type | Description |
|---|---|---|
mode / variation_unique / … / updated_at |
— | Same as the content list |
publish[] |
object | Posting results by destination |
publish[].platform |
string | The destination. youtube / tiktok / instagram / x / wordpress / webhook |
publish[].status |
string | The posting result. success (posted) / failed / scheduled (scheduled on the destination) / processing / partial_success (the post succeeded but a related step such as setting the thumbnail failed) / reviewing (the post succeeded and is waiting for the destination's review, so the URL is not yet available) |
publish[].post_url |
string | null | The URL of the post (null if not yet available) |
publish[].posted_at |
string | null | When it was posted (null if not posted) |
content |
object | Generated data by mode. blogmode: body_html (article body) / carouselmode: scenario (slide structure; resolved_header, resolved_footer, and resolved_texts are the text in the publishing language) / moviemode: movie_unique, download_url, thumbnail_url (the video selected in Step3; the URLs are valid for one hour after they are issued) / postmode: posts (post_text, post_type) |
package |
object | null | What is posted. null for postmode |
package.video (moviemode) |
object | null | The finished video {file: {url, expires_at}, thumbnail: {url, expires_at} | null, duration_ms, width, height, size_bytes}. null if there is none yet |
package.youtube.title (moviemode) |
string | null | The YouTube title |
package.youtube.description (moviemode) |
string | null | The YouTube description (including the fixed text) |
package.youtube.thumbnail (moviemode) |
object | null | The YouTube thumbnail {url, expires_at, width, height}. null for vertical videos (Shorts), which do not set a thumbnail |
package.tiktok.caption (moviemode) |
string | null | The TikTok caption |
package.instagram.caption (moviemode / carouselmode) |
string | null | The Instagram caption (including the fixed text) |
package.instagram.cover (moviemode) |
object | null | The Instagram cover image {url, expires_at, width, height} |
package.x.posts (moviemode / blogmode / carouselmode) |
array | The X posts. Two or more are posted in order as a thread |
package.title (blogmode) |
string | null | The article title |
package.meta_description (blogmode) |
string | null | The meta description |
package.body_html (blogmode) |
string | null | The HTML of the article body. Images are in the src of img tags, and affiliate codes and reviews for product introductions are already filled in. You can publish it on your site as is |
package.images (blogmode) |
array | Images in the body (in order of appearance) {url, expires_at, width, height} |
package.cover_image (blogmode) |
object | null | The cover image {url, expires_at, width, height} |
package.audio (blogmode) |
object | null | The article audio {url, expires_at, duration_ms} |
package.slides[] (carouselmode) |
object | Slide images (in slide order) {no, image: {url, expires_at, width, height}} |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 404 | not_found |
The content does not exist (Content not found.) |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/bmA1b2C3d4E5" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/bmA1b2C3d4E5",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/bmA1b2C3d4E5", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/bmA1b2C3d4E5")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/bmA1b2C3d4E5"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/bmA1b2C3d4E5");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"mode": "blogmode",
"variation_unique": "bmA1b2C3d4E5",
"variation_no": 1,
"title": "How to start a blog",
"concept": { "concept_unique": "bC2x8RtY4u", "concept_name": "Blogging for beginners" },
"title_unique": "bT9kL3mN5pQr",
"language": "en",
"status": "complete",
"progress": { "step_process": 3, "is_complete": true },
"is_readonly": false,
"confirmed_at": "2026-10-03T08:00:00Z",
"published_platforms": ["wordpress"],
"created_at": "2026-10-01T03:00:00Z",
"updated_at": "2026-10-03T08:00:00Z",
"publish": [
{
"platform": "wordpress",
"status": "success",
"post_url": "https://blog.example.com/how-to-start-a-blog/",
"posted_at": "2026-10-03T09:00:00Z"
}
],
"content": { "body_html": "<h2>Why start a blog</h2><p>...</p>" },
"package": {
"title": "How to Start a Blog in 2026",
"meta_description": "A step-by-step guide to starting your first blog.",
"body_html": "<h2>Why start a blog</h2><p>...</p><figure><img src=\"https://...\" alt=\"\" /></figure>",
"images": [
{ "url": "https://...", "expires_at": "2026-10-21T10:00:00Z", "width": 1536, "height": 1024 }
],
"cover_image": { "url": "https://...", "expires_at": "2026-10-21T10:00:00Z", "width": 1200, "height": 630 },
"audio": null,
"x": { "posts": ["New post: How to start a blog in 2026"] }
}
}
}
Posting schedule
Gets the planned, generated, and posted posts of post (postmode) content in order of scheduled time. It also includes the automation status.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
variation_unique |
Path | string | Required | — | The content ID |
from |
Query | string | Optional | — | The start of the range (scheduled time). 7 days ago when omitted (37 days before to when only to is given) |
to |
Query | string | Optional | — | The end of the range (scheduled time). 30 days from now when omitted (37 days after from when only from is given). The range can be up to 92 days |
| Field | Type | Description |
|---|---|---|
automation.enabled |
boolean | Whether automation is on |
automation.is_executing |
boolean | Whether automatic post creation is running (true even after automation is turned off while posts being created remain) |
automation.week_limit |
integer | null | The maximum number of weeks of plans created automatically (null means unlimited) |
automation.week_count |
integer | The number of weeks of plans created so far |
automation.next_refill_at |
string | null | When the next plans will be created (null when automation is off) |
automation.timezone |
string | null | The time zone used for the posting schedule |
items[].schedule_unique |
string | The schedule ID |
items[].post_unique |
string | null | The post ID (null if the text has not been created yet) |
items[].scheduled_at |
string | When the post is scheduled |
items[].scheduled_at_local |
string | The scheduled time in the local time of timezone (YYYY-MM-DDTHH:MM:SS) |
items[].timezone |
string | The time zone when the plan was created |
items[].status |
string | The status. pending (waiting for the text to be created) / generating / generated (created, waiting to be posted) / awaiting_image (waiting for the image) / posting / posted / failed / discarded (canceled) |
items[].confirm_state |
string | Review before posting. none (no review) / awaiting (not posted until approved) / notice (posted after a notification only) / confirmed (approved) |
items[].created_via |
string | How it was created. auto (automation) / manual |
items[].post_type |
string | The post type. assertion (opinion) / knowhow / experience / casual (everyday) |
items[].text |
string | null | The post text (null if it has not been created yet) |
items[].thread[] |
object | The following posts in the thread (in order) {text, post_url, posted_at, image} |
items[].image |
object | null | The attached image {url, expires_at} (null if none) |
items[].post_url |
string | null | The URL of the post (null if not yet available) |
items[].posted_at |
string | null | When it was posted (null if not posted) |
items[].fail_reason |
string | null | The reason for failure. token_expired (X disconnected) / api_error (X error) / not_found (the post target does not exist) / insufficient_token (not enough tokens) / no_x_account (no X account set) / node_ws_timeout (text creation timed out) / unconfirmed (not approved) |
meta.from / meta.to |
string | The range that was returned |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 404 | not_found |
The content is not post (postmode) content, or the content does not exist (Schedule not found.) |
| 422 | validation_error |
The range is longer than 92 days, to is earlier than from, or a value cannot be read as a date and time |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/pmV7d2KqLx01/schedule?from=2026-10-20&to=2026-11-20" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/pmV7d2KqLx01/schedule?from=2026-10-20&to=2026-11-20",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/pmV7d2KqLx01/schedule?from=2026-10-20&to=2026-11-20", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/pmV7d2KqLx01/schedule?from=2026-10-20&to=2026-11-20")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/pmV7d2KqLx01/schedule?from=2026-10-20&to=2026-11-20"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/contents/pmV7d2KqLx01/schedule?from=2026-10-20&to=2026-11-20");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"automation": {
"enabled": true,
"is_executing": true,
"week_limit": 4,
"week_count": 2,
"next_refill_at": "2026-10-26T15:00:00Z",
"timezone": "Asia/Tokyo"
},
"items": [
{
"schedule_unique": "Sc1aB2cD3eF4",
"post_unique": "Po1aB2cD3eF4",
"scheduled_at": "2026-10-20T01:00:00Z",
"scheduled_at_local": "2026-10-20T10:00:00",
"timezone": "Asia/Tokyo",
"status": "posted",
"confirm_state": "none",
"created_via": "auto",
"post_type": "assertion",
"text": "Most teams overthink onboarding...",
"thread": [
{
"text": "First, cut the steps in half.",
"post_url": "https://x.com/i/status/1848000000000000002",
"posted_at": "2026-10-20T01:00:05Z",
"image": null
}
],
"image": { "url": "https://...", "expires_at": "2026-10-21T10:00:00Z" },
"post_url": "https://x.com/i/status/1848000000000000001",
"posted_at": "2026-10-20T01:00:00Z",
"fail_reason": null
},
{
"schedule_unique": "Sc9zY8xW7vU6",
"post_unique": null,
"scheduled_at": "2026-10-25T09:30:00Z",
"scheduled_at_local": "2026-10-25T18:30:00",
"timezone": "Asia/Tokyo",
"status": "pending",
"confirm_state": "none",
"created_via": "auto",
"post_type": "knowhow",
"text": null,
"thread": [],
"image": null,
"post_url": null,
"posted_at": null,
"fail_reason": null
}
]
},
"meta": { "from": "2026-10-20T00:00:00Z", "to": "2026-11-20T23:59:59Z" }
}
Posting results & destinations
Get posting results for social networks, WordPress, and webhooks, and the status of the destinations connected to a project. You can use this to detect disconnected accounts.
-
Posting results
GET /projects/{project_unique}/publications -
Connected destinations
GET /projects/{project_unique}/accounts
Posting results
Gets the posting results of a project across all modes and destinations, newest first. For post (postmode), posted and failed posts (the first post of each thread) are included.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
mode |
Query | string | Optional | — | Filter by mode. You can pass several of moviemode (video), blogmode (blog), carouselmode (carousel), and postmode (post), separated by commas. All modes when omitted |
platform |
Query | string | Optional | — | Filter by destination (comma-separated). youtube / tiktok / instagram / x / wordpress / webhook |
status |
Query | string | Optional | — | Filter by posting result (comma-separated). success / failed / scheduled / processing / partial_success / reviewing |
from |
Query | string | Optional | — | Returns only results whose posting time (the recorded time if not posted) is at or after this date and time |
to |
Query | string | Optional | — | Returns only results whose posting time (the recorded time if not posted) is at or before this date and time |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].publication_unique |
string | The posting result ID |
data[].mode |
string | moviemode (video) / blogmode (blog) / carouselmode (carousel) / postmode (post) |
data[].content.variation_unique |
string | The content ID |
data[].content.title |
string | null | The title (the concept name for postmode) |
data[].platform |
string | The destination. youtube / tiktok / instagram / x / wordpress / webhook |
data[].account |
object | null | The account used to post {account_unique, name}. name is null for disconnected accounts. null for postmode |
data[].status |
string | The posting result. success (posted) / failed / scheduled (scheduled on the destination) / processing / partial_success (the post succeeded but a related step such as setting the thumbnail failed) / reviewing (the post succeeded and is waiting for the destination's review, so the URL is not yet available) |
data[].post_url |
string | null | The URL of the post (null if not yet available) |
data[].error_code |
string | null | The failure reason code (differs by destination and mode) |
data[].is_automation |
boolean | Whether it was posted by an automation |
data[].posted_at |
string | null | When it was posted (null if not posted) |
data[].created_at |
string | Created at |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/publications?platform=x,youtube&status=failed" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/publications?platform=x,youtube&status=failed",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/publications?platform=x,youtube&status=failed", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/publications?platform=x,youtube&status=failed")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/publications?platform=x,youtube&status=failed"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/publications?platform=x,youtube&status=failed");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"publication_unique": "Xk3p9QmZ2aLw",
"mode": "postmode",
"content": { "variation_unique": "pmV7d2KqLx01", "title": "SaaS growth tips" },
"platform": "x",
"account": null,
"status": "success",
"post_url": "https://x.com/i/status/1848123456789012345",
"error_code": null,
"is_automation": true,
"posted_at": "2026-10-04T10:00:00Z",
"created_at": "2026-10-03T22:10:00Z"
},
{
"publication_unique": "Pq2wE4rT6yUi",
"mode": "blogmode",
"content": { "variation_unique": "bmA1b2C3d4E5", "title": "How to start a blog" },
"platform": "wordpress",
"account": { "account_unique": "Wp3xY5zA7bCd", "name": "My WP Site" },
"status": "failed",
"post_url": null,
"error_code": "auth_failed",
"is_automation": false,
"posted_at": null,
"created_at": "2026-10-02T10:00:00Z"
}
],
"meta": { "total": 2, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Connected destinations
Gets the destinations connected to a project (social network accounts, WordPress, and webhooks) and their status. When status becomes expired or connection_error, you need to reconnect.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
| Field | Type | Description |
|---|---|---|
data[].account_unique |
string | The destination ID |
data[].platform |
string | The destination. youtube / tiktok / instagram / x / wordpress / webhook |
data[].name |
string | The display name. The account name for social networks, the site name for WordPress, and the domain of the destination URL for webhooks (in the form https://example.com/…, without the path) |
data[].image_url |
string | null | The URL of the account's icon image (social networks only) |
data[].status |
string | The status. active / expired (the connection expired and you need to reconnect) / connection_error (cannot connect to WordPress) |
data[].token_expires_at |
string | null | When the connection expires (social networks only) |
data[].connected_at |
string | null | When it was connected |
data[].last_used_at |
string | null | When it was last used to post (social networks only) |
data[].site_url |
string | null | The WordPress site URL (WordPress only) |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
curl "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/accounts" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/accounts",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/accounts", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/accounts")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/accounts"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/a1b2c3d4e5/accounts");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"account_unique": "Wh9kQ2mN4pRt",
"platform": "webhook",
"name": "https://hooks.example.com/…",
"image_url": null,
"status": "active",
"token_expires_at": null,
"connected_at": "2026-07-01T00:00:00Z",
"last_used_at": null,
"site_url": null
},
{
"account_unique": "Wp3xY5zA7bCd",
"platform": "wordpress",
"name": "Example Blog",
"image_url": null,
"status": "connection_error",
"token_expires_at": null,
"connected_at": "2026-08-01T00:00:00Z",
"last_used_at": null,
"site_url": "https://blog.example.com"
},
{
"account_unique": "Yt4aB6cD8eFg",
"platform": "youtube",
"name": "Channel B",
"image_url": "https://yt3.ggpht.com/abc.png",
"status": "expired",
"token_expires_at": "2026-10-01T00:00:00Z",
"connected_at": "2026-09-01T00:00:00Z",
"last_used_at": "2026-09-30T00:00:00Z",
"site_url": null
}
]
}
Automations
Get the progress of video, blog, and carousel automations. For post automation (post mode), use the content schedule.
Automation runs
Gets video, blog, and carousel automation runs (scheduled, running, waiting for review, and finished), newest first.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
status |
Query | string | Optional | — | Filter by status (comma-separated). The values are the same as data[].status |
mode |
Query | string | Optional | — | Filter by mode (comma-separated). moviemode / blogmode / carouselmode |
project_unique |
Query | string | Optional | — | Returns only the runs of this project |
from |
Query | string | Optional | — | Returns only items created at or after this date and time |
to |
Query | string | Optional | — | Returns only items created at or before this date and time |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].auto_unique |
string | The automation run ID |
data[].mode |
string | moviemode / blogmode / carouselmode |
data[].project |
object | The project {project_unique, project_name} |
data[].content.variation_unique |
string | The content ID |
data[].content.title |
string | null | The title (the concept name for postmode) |
data[].run_no |
integer | Which run this is for the content |
data[].status |
string | The status. pending (waiting to run; scheduled when reserved_at is in the future) / executing / waiting_confirmation (waiting for content review; continues after review) / completed / failed / stopped / token_shortage (ended because tokens ran out) |
data[].execution_mode |
string | sequential (one at a time) / parallel (all at once) |
data[].phases |
object | The status of each stage {setup, step1, step2, step3}. not_started / running (running, or the stage where it stopped) / done (including skipped, and, for a run that finished as completed, steps that were out of this run's scope) / error |
data[].template.setup_template_name |
string | null | The name of the setup template used at the start |
data[].template.automation_template_name |
string | null | The name of the automation template used at the start |
data[].reserved_at |
string | null | The scheduled run time |
data[].executed_at |
string | null | When the run started |
data[].completed_at |
string | null | When the run ended (for completed / failed / stopped / token_shortage) |
data[].created_at |
string | When the run was registered |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/automations?status=executing,waiting_confirmation" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/automations?status=executing,waiting_confirmation",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/automations?status=executing,waiting_confirmation", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/automations?status=executing,waiting_confirmation")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/automations?status=executing,waiting_confirmation"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/automations?status=executing,waiting_confirmation");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"auto_unique": "aB3dE5fG7h",
"mode": "blogmode",
"project": { "project_unique": "Pq7Rs9Tu1V", "project_name": "My Blog" },
"content": { "variation_unique": "Xy12Zw34Ab56", "title": "Spring travel tips" },
"run_no": 2,
"status": "executing",
"execution_mode": "sequential",
"phases": { "setup": "done", "step1": "done", "step2": "running", "step3": "not_started" },
"template": { "setup_template_name": "Default setup", "automation_template_name": null },
"reserved_at": null,
"executed_at": "2026-10-19T23:09:27Z",
"completed_at": null,
"created_at": "2026-10-19T23:09:20Z"
}
],
"meta": { "total": 1, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Automation run detail
Gets the details of one automation run and the progress of each step. You can use this to check progress while it is running.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
auto_unique |
Path | string | Required | — | The automation run ID |
| Field | Type | Description |
|---|---|---|
auto_unique / mode / … / created_at |
— | Same as the automation run list |
steps[].key |
string | The step name (for example, step1_1_text) |
steps[].phase |
string | The stage of the step. setup / step1 / step2 / step3 |
steps[].status |
string | The step status. not_run (not run, not applicable, or running) / success / error / skipped |
steps[].started_at |
string | null | When the running step started (null once it ends) |
steps[].finished_at |
string | null | When the step ended |
steps[].duration_sec |
number | null | How many seconds it took (for success / error) |
steps[].error_code |
string | null | The reason for failure. internal_error / automation_record_not_found / automation_stopped_by_admin / insufficient_token (not enough tokens) / token_group_blocked (team quota reached) / automation_auth_error (destination authentication error) / automation_not_found / automation_conflict / automation_rate_limited (destination rate limit) / automation_server_error (destination error) / automation_network_error (network error) / automation_step_failed. null when no reason was recorded |
accounts[] |
object | The destinations at the start {platform, account_unique, name}. name is null for webhooks |
stop_requested |
boolean | Whether a stop was requested |
confirmation.requested_at |
string | null | When it started waiting for review |
confirmation.confirmed_at |
string | null | When the content was reviewed (null while waiting for review or when not reviewed) |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The automation does not exist or is outside what you can access (Automation not found.) |
curl "https://stepeee.com/public-api/v1/automations/aB3dE5fG7h" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/automations/aB3dE5fG7h",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/automations/aB3dE5fG7h", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/automations/aB3dE5fG7h")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/automations/aB3dE5fG7h"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/automations/aB3dE5fG7h");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"auto_unique": "aB3dE5fG7h",
"mode": "blogmode",
"project": { "project_unique": "Pq7Rs9Tu1V", "project_name": "My Blog" },
"content": { "variation_unique": "Xy12Zw34Ab56", "title": "Spring travel tips" },
"run_no": 2,
"status": "failed",
"execution_mode": "sequential",
"phases": { "setup": "done", "step1": "done", "step2": "done", "step3": "error" },
"template": { "setup_template_name": "Default setup", "automation_template_name": "Skip voice" },
"reserved_at": null,
"executed_at": "2026-10-19T23:09:27Z",
"completed_at": "2026-10-19T23:17:50Z",
"created_at": "2026-10-19T23:09:20Z",
"steps": [
{ "key": "setup", "phase": "setup", "status": "success", "started_at": null, "finished_at": "2026-10-19T23:09:29Z", "duration_sec": 2.3, "error_code": null },
{ "key": "step1_1_text", "phase": "step1", "status": "success", "started_at": null, "finished_at": "2026-10-19T23:11:40Z", "duration_sec": 126.3, "error_code": null },
{ "key": "step2_2", "phase": "step2", "status": "skipped", "started_at": null, "finished_at": "2026-10-19T23:17:39Z", "duration_sec": null, "error_code": null },
{ "key": "step3_4_webhook", "phase": "step3", "status": "error", "started_at": null, "finished_at": "2026-10-19T23:17:46Z", "duration_sec": 1.2, "error_code": "automation_server_error" }
],
"accounts": [
{ "platform": "webhook", "account_unique": "dxuD2gk3NYuA", "name": null }
],
"stop_requested": false,
"confirmation": { "requested_at": null, "confirmed_at": null }
}
}
Materials & templates
Get My Materials (images, videos, and BGM) and setup and automation templates.
-
Material list
GET /materials -
Material detail
GET /materials/{material_unique} -
Material categories
GET /material-categories -
Template list
GET /templates
Material list
Gets My Materials (images, videos, and BGM), newest first. On a team, it returns the materials shared by the team. Images used only inside a project and files still being created are not included.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
kind |
Query | string | Optional | — | Filter by material kind. image / video / bgm |
type |
Query | string | Optional | — | Filter by material use. The values are the same as data[].type. When used with kind, only uses of that kind can be specified |
category_unique |
Query | string | Optional | — | Returns only the materials in this category |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].material_unique |
string | The material ID |
data[].kind |
string | The kind. image / video / bgm |
data[].type |
string | The use. image_element (element) / image_vertical (vertical) / image_horizontal (horizontal) / reference_person (reference image: person) / reference_background (reference image: background) / reference_layout (reference image: layout) / reference_cta (reference image: CTA) / reference_pet (reference image: pet) / reference_product (reference image: product or service) / image_rulebook (rulebook) / image_promotion (promotion) / video_vertical (vertical video) / video_horizontal (horizontal video) / bgm |
data[].category |
object | null | The category {category_unique, name} (null if uncategorized) |
data[].width / data[].height |
integer | null | Width and height (pixels). null for BGM and when not measured |
data[].duration_ms |
integer | null | Length (milliseconds). null for images |
data[].size_bytes |
integer | File size (bytes). It may be 0 when the material refers to a shared material |
data[].file |
object | The file itself {url, expires_at} |
data[].thumbnail |
object | null | The thumbnail {url, expires_at}. null for BGM, elements, and while the thumbnail is being created |
data[].created_at |
string | Created at |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/materials?kind=image&type=image_vertical" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/materials?kind=image&type=image_vertical",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/materials?kind=image&type=image_vertical", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/materials?kind=image&type=image_vertical")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/materials?kind=image&type=image_vertical"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/materials?kind=image&type=image_vertical");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"material_unique": "aB3dE5fG7hJ9kL1mN3pQ",
"kind": "image",
"type": "image_vertical",
"category": { "category_unique": "Xy12Ab34Cd", "name": "Product shots" },
"width": 768,
"height": 1344,
"duration_ms": null,
"size_bytes": 1234567,
"file": { "url": "https://...", "expires_at": "2026-10-21T11:00:00Z" },
"thumbnail": { "url": "https://...", "expires_at": "2026-10-21T11:00:00Z" },
"created_at": "2026-10-01T10:00:00Z"
},
{
"material_unique": "Zq9Wv8Ut7Sr6Qp5On4Ml",
"kind": "bgm",
"type": "bgm",
"category": null,
"width": null,
"height": null,
"duration_ms": 30000,
"size_bytes": 482133,
"file": { "url": "https://...", "expires_at": "2026-10-21T11:00:00Z" },
"thumbnail": null,
"created_at": "2026-09-30T08:12:00Z"
}
],
"meta": { "total": 2, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Material detail
Gets one material.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
material_unique |
Path | string | Required | — | The material ID |
| Field | Type | Description |
|---|---|---|
material_unique |
string | The material ID |
kind |
string | The kind. image / video / bgm |
type |
string | The use. image_element (element) / image_vertical (vertical) / image_horizontal (horizontal) / reference_person (reference image: person) / reference_background (reference image: background) / reference_layout (reference image: layout) / reference_cta (reference image: CTA) / reference_pet (reference image: pet) / reference_product (reference image: product or service) / image_rulebook (rulebook) / image_promotion (promotion) / video_vertical (vertical video) / video_horizontal (horizontal video) / bgm |
category |
object | null | The category {category_unique, name} (null if uncategorized) |
width / height |
integer | null | Width and height (pixels). null for BGM and when not measured |
duration_ms |
integer | null | Length (milliseconds). null for images |
size_bytes |
integer | File size (bytes). It may be 0 when the material refers to a shared material |
file |
object | The file itself {url, expires_at} |
thumbnail |
object | null | The thumbnail {url, expires_at}. null for BGM, elements, and while the thumbnail is being created |
created_at |
string | Created at |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The material does not exist or is outside what you can access (Material not found.) |
curl "https://stepeee.com/public-api/v1/materials/aB3dE5fG7hJ9kL1mN3pQ" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/materials/aB3dE5fG7hJ9kL1mN3pQ",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/materials/aB3dE5fG7hJ9kL1mN3pQ", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/materials/aB3dE5fG7hJ9kL1mN3pQ")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/materials/aB3dE5fG7hJ9kL1mN3pQ"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/materials/aB3dE5fG7hJ9kL1mN3pQ");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"material_unique": "aB3dE5fG7hJ9kL1mN3pQ",
"kind": "image",
"type": "image_vertical",
"category": { "category_unique": "Xy12Ab34Cd", "name": "Product shots" },
"width": 768,
"height": 1344,
"duration_ms": null,
"size_bytes": 1234567,
"file": { "url": "https://...", "expires_at": "2026-10-21T11:00:00Z" },
"thumbnail": { "url": "https://...", "expires_at": "2026-10-21T11:00:00Z" },
"created_at": "2026-10-01T10:00:00Z"
}
}
Material categories
Gets material categories and the number of materials in each category.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
kind |
Query | string | Optional | — | Filter by material kind. image / video / bgm |
type |
Query | string | Optional | — | Filter by material use. The values are the same as data[].type. When used with kind, only uses of that kind can be specified |
| Field | Type | Description |
|---|---|---|
data[].category_unique |
string | The category ID |
data[].name |
string | The category name |
data[].kind |
string | The kind. image / video / bgm |
data[].type |
string | The use. image_element (element) / image_vertical (vertical) / image_horizontal (horizontal) / reference_person (reference image: person) / reference_background (reference image: background) / reference_layout (reference image: layout) / reference_cta (reference image: CTA) / reference_pet (reference image: pet) / reference_product (reference image: product or service) / image_rulebook (rulebook) / image_promotion (promotion) / video_vertical (vertical video) / video_horizontal (horizontal video) / bgm |
data[].is_system |
boolean | Whether the category was created automatically by Stepeee |
data[].material_count |
integer | The number of materials in the category |
| Status | Code | When |
|---|---|---|
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/material-categories?kind=image" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/material-categories?kind=image",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/material-categories?kind=image", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/material-categories?kind=image")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/material-categories?kind=image"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/material-categories?kind=image");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"category_unique": "Bg12Ab34Cd",
"name": "Lo-fi",
"kind": "bgm",
"type": "bgm",
"is_system": false,
"material_count": 3
},
{
"category_unique": "Xy12Ab34Cd",
"name": "Product shots",
"kind": "image",
"type": "image_vertical",
"is_system": false,
"material_count": 12
}
]
}
Template list
Gets setup templates and automation templates, most recently updated first. On a team, it returns the templates shared by the team.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
mode |
Query | string | Optional | — | Filter by mode. moviemode / blogmode / carouselmode / postmode |
kind |
Query | string | Optional | — | Filter by template kind. setup / automation |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].template_unique |
string | The template ID |
data[].kind |
string | The kind. setup (a setup template that holds the production settings) / automation (an automation template that decides which steps an automation runs) |
data[].mode |
string | moviemode (video) / blogmode (blog) / carouselmode (carousel) / postmode (post) |
data[].name |
string | The template name |
data[].created_at |
string | Created at |
data[].updated_at |
string | Updated at |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/templates?kind=setup&mode=moviemode" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/templates?kind=setup&mode=moviemode",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/templates?kind=setup&mode=moviemode", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/templates?kind=setup&mode=moviemode")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/templates?kind=setup&mode=moviemode"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/templates?kind=setup&mode=moviemode");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"template_unique": "Ab12Cd34Ef",
"kind": "automation",
"mode": "carouselmode",
"name": "Morning carousel",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-10-05T00:00:00Z"
},
{
"template_unique": "Gh56Ij78Kl",
"kind": "setup",
"mode": "moviemode",
"name": "60-second explainer",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-10-01T00:00:00Z"
}
],
"meta": { "total": 2, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Titles and content
Add titles to video, blog, and carousel concepts, and delete content. Available on the Pro plan or higher.
-
Add titles
POST /projects/{project_unique}/concepts/{concept_unique}/titles -
Delete content
DELETE /projects/{project_unique}/contents/{variation_unique}
Add titles
Adds titles in bulk to a video, blog, or carousel concept and creates the first piece of content for each title (the same process as adding titles manually in the app). Titles whose name already exists in the same concept, and names that appear a second time or later in the same request, are not created and are listed in skipped. A concept can have up to 100 titles; if adding would exceed 100, nothing is created (names that are skipped are not counted). If the plan's content limit prevents creating content, only the title is created and variation_unique is null. Blog promotion and affiliate settings cannot be set; titles are created as standard articles. On a team, the titles and content you create are owned by the team owner.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
concept_unique |
Path | string | Required | — | The concept ID (a video, blog, or carousel concept in the project) |
titles |
Body | string[] | Required | — | The titles to add. 1–50 items. Each must be 1–500 characters after trimming leading and trailing spaces (titles that are only spaces, only "0", or contain a line break in the middle are not allowed) |
language |
Body | string | Required | — | The publishing language of the content to create (a language code such as ja, en, or fr) |
setup_template_unique |
Body | string | null | Optional | — | The ID of the setup template to apply (template_unique from GET /templates, with kind setup and the same mode as the concept). If omitted, the concept's default template is used (or the standard setup if none is set) |
Idempotency-Key |
Header | string | Optional | — | A unique string you generate for each action (1–255 printable ASCII characters). Resending the same content with the same key returns the first response as is (see Idempotency-Key in the conventions) |
| Field | Type | Description |
|---|---|---|
created[] |
object | The titles created (in the order sent) |
created[].title_unique |
string | The title ID |
created[].title_name |
string | The title name (with leading and trailing spaces removed) |
created[].variation_unique |
string | null | The ID of the first content created. null if it could not be created because of the plan's content limit |
skipped[] |
object | The titles not created (in the order sent) |
skipped[].title_name |
string | The title name (with leading and trailing spaces removed) |
skipped[].reason |
string | Why it was not created. duplicate (a title with the same name exists in the same concept, or the name appeared a second time or later in the same request) |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 403 | readonly |
The project is read-only because of a plan downgrade |
| 404 | not_found |
The project does not exist or is outside your access, or the concept is not in that project |
| 409 | title_limit_reached |
Adding would make the concept exceed 100 titles (nothing is created). details contains limit (the limit) and total_after_add (the count after adding) |
| 409 | idempotency_request_in_progress |
A request with the same Idempotency-Key is still being processed |
| 422 | validation_error |
The parameters are invalid |
| 422 | mode_not_supported |
A post (post mode) or photo concept |
| 422 | template_not_found |
The template for setup_template_unique does not exist (including templates of another mode) |
| 422 | idempotency_key_invalid |
The Idempotency-Key is not 1–255 printable ASCII characters |
| 422 | idempotency_key_reused |
The same Idempotency-Key was used for a request with different content |
curl -X POST "https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/concepts/Cx2Yz4Wv6Ut8/titles" \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90" \
-d '{
"titles": ["Easy breakfast for beginners", "Meal prep in 5 minutes", "Easy breakfast for beginners"],
"language": "en",
"setup_template_unique": "Gh56Ij78Kl"
}'
import requests
body = """{
"titles": ["Easy breakfast for beginners", "Meal prep in 5 minutes", "Easy breakfast for beginners"],
"language": "en",
"setup_template_unique": "Gh56Ij78Kl"
}"""
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/concepts/Cx2Yz4Wv6Ut8/titles",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
data=body,
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/concepts/Cx2Yz4Wv6Ut8/titles", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
body: JSON.stringify({
"titles": ["Easy breakfast for beginners", "Meal prep in 5 minutes", "Easy breakfast for beginners"],
"language": "en",
"setup_template_unique": "Gh56Ij78Kl"
}),
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/concepts/Cx2Yz4Wv6Ut8/titles")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90", forHTTPHeaderField: "Idempotency-Key")
request.httpBody = """
{
"titles": ["Easy breakfast for beginners", "Meal prep in 5 minutes", "Easy breakfast for beginners"],
"language": "en",
"setup_template_unique": "Gh56Ij78Kl"
}
""".data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/concepts/Cx2Yz4Wv6Ut8/titles"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"titles": ["Easy breakfast for beginners", "Meal prep in 5 minutes", "Easy breakfast for beginners"],
"language": "en",
"setup_template_unique": "Gh56Ij78Kl"
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$body = <<<'JSON'
{
"titles": ["Easy breakfast for beginners", "Meal prep in 5 minutes", "Easy breakfast for beginners"],
"language": "en",
"setup_template_unique": "Gh56Ij78Kl"
}
JSON;
$ch = curl_init("https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/concepts/Cx2Yz4Wv6Ut8/titles");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type: application/json",
"Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"created": [
{ "title_unique": "q8Wm2LzR0pXa", "title_name": "Easy breakfast for beginners", "variation_unique": "Hn4kT7sY1cVb" },
{ "title_unique": "Zr5uP9eK3wQd", "title_name": "Meal prep in 5 minutes", "variation_unique": "Jm8oB2fN6xLe" }
],
"skipped": [
{ "title_name": "Easy breakfast for beginners", "reason": "duplicate" }
]
}
}
Delete content
Deletes video, blog, or carousel content (the same process as deleting content in the app). Deleted content disappears from lists and details immediately, and after delete_at (3 days later) it is permanently deleted together with the generated images, audio, and videos. The numbers of later content under the same title are shifted down by one. Content whose automation is running or scheduled cannot be deleted, so stop the automation first. The last remaining content of a title and post (post mode) content cannot be deleted.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
project_unique |
Path | string | Required | — | The project ID |
variation_unique |
Path | string | Required | — | The content ID |
| Field | Type | Description |
|---|---|---|
variation_unique |
string | The ID of the deleted content |
mode |
string | moviemode (video) / blogmode (blog) / carouselmode (carousel) |
delete_at |
string | When it will be permanently deleted |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 403 | readonly |
The content or project is read-only because of a plan downgrade |
| 404 | not_found |
The project does not exist or is outside your access, the content is not in that project, or it has already been deleted |
| 409 | automation_running |
An automation is running or scheduled (an automation has not finished) |
| 409 | last_variation |
The last remaining content of the title |
| 422 | mode_not_supported |
Post (post mode) content |
curl -X DELETE "https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/contents/Hn4kT7sY1cVb" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.request(
"DELETE",
"https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/contents/Hn4kT7sY1cVb",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/contents/Hn4kT7sY1cVb", {
method: "DELETE",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/contents/Hn4kT7sY1cVb")!)
request.httpMethod = "DELETE"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/contents/Hn4kT7sY1cVb"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.method("DELETE", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/projects/Pq7Rs9Tu1V/contents/Hn4kT7sY1cVb");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"variation_unique": "Hn4kT7sY1cVb",
"mode": "blogmode",
"delete_at": "2026-10-24T12:00:00Z"
}
}
Automation actions
Start, reschedule, and stop video, blog, and carousel automations, and approve automations waiting for review. You can act on the same automations you can get with GET /automations. Stopping is available on any plan; everything else requires the Pro plan or higher.
-
Start an automation
POST /automations -
Change the scheduled time
PATCH /automations/{auto_unique} -
Stop an automation
POST /automations/{auto_unique}/stop -
Approve an automation waiting for review
POST /automations/{auto_unique}/confirm
Start an automation
Starts automations for content (video, blog, or carousel) using the settings of an automation template. If you specify reserved_at, it is scheduled; if omitted, it runs now. All target content must be in the same project and the same mode. If even one piece of content cannot be started, nothing is registered, and the response says which content cannot be started and why. Steps already created are skipped and the run starts from the first step not yet created; steps set to skip in the automation template are not run. For content whose setup is not complete, the setup is finalized at the start. Running an automation consumes Token. The balance is not checked at the start; if it runs out during the run, the automation ends with token_shortage. With settings that require content review, the automation stops at waiting_confirmation partway through, so approve it with POST /automations/{auto_unique}/confirm. Automatic posting for posts (post mode) is a separate mechanism turned on and off per content in the app and cannot be started with this endpoint.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
variation_uniques |
Body | string[] | Required | — | The variation_unique of the content to automate. 1–50 items, no duplicates |
automation_template_unique |
Body | string | Required | — | The automation template ID (template_unique from GET /templates?kind=automation, with the same mode as the content) |
reserved_at |
Body | string | Optional | — | When to run (a future date and time). Specify it in ISO 8601 with a time offset; values without an offset are treated as UTC. If omitted, it runs now |
Idempotency-Key |
Header | string | Required | — | A unique string you generate for each action (1–255 printable ASCII characters). Required to prevent starting twice. Resending the same content with the same key returns the first response as is (see Idempotency-Key in the conventions) |
| Field | Type | Description |
|---|---|---|
data[].auto_unique |
string | The automation run ID |
data[].mode |
string | moviemode / blogmode / carouselmode |
data[].project |
object | The project {project_unique, project_name} |
data[].content.variation_unique |
string | The content ID |
data[].content.title |
string | null | The title (the concept name for postmode) |
data[].run_no |
integer | Which run this is for the content |
data[].status |
string | The status. pending (waiting to run; scheduled when reserved_at is in the future) / executing / waiting_confirmation (waiting for content review; continues after review) / completed / failed / stopped / token_shortage (ended because tokens ran out) |
data[].execution_mode |
string | sequential (one at a time) / parallel (all at once) |
data[].phases |
object | The status of each stage {setup, step1, step2, step3}. not_started / running (running, or the stage where it stopped) / done (including skipped, and, for a run that finished as completed, steps that were out of this run's scope) / error |
data[].template.setup_template_name |
string | null | The name of the setup template used at the start |
data[].template.automation_template_name |
string | null | The name of the automation template used at the start |
data[].reserved_at |
string | null | The scheduled run time |
data[].executed_at |
string | null | When the run started |
data[].completed_at |
string | null | When the run ended (for completed / failed / stopped / token_shortage) |
data[].created_at |
string | When the run was registered |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 404 | content_not_found |
A variation_unique is not among the content you can access (including content scheduled for deletion after the request was accepted; nothing is registered). details.variation_uniques contains the matching ones |
| 404 | automation_template_not_found |
The automation template does not exist |
| 409 | contents_not_automatable |
Some content cannot be started (nothing is registered). details.contents contains an array of {variation_unique, reason}. reason is automation_active (an automation has not finished) / content_complete (already complete) / content_readonly (read-only because of a plan downgrade) / layout_template_required (carousel content in a project with no layout template) |
| 409 | idempotency_request_in_progress |
A request with the same Idempotency-Key is still being processed |
| 422 | validation_error |
The parameters are invalid (0 items or 51 or more items, duplicates, reserved_at in the past, and so on) |
| 422 | mode_not_supported |
Post (post mode) content is included. details.variation_uniques contains the matching ones |
| 422 | mixed_modes |
Content of different modes is mixed |
| 422 | mixed_projects |
Content of different projects is mixed |
| 422 | automation_template_mode_mismatch |
The automation template's mode differs from the content's mode |
| 422 | idempotency_key_required |
The Idempotency-Key header is missing |
| 422 | idempotency_key_invalid |
The Idempotency-Key is not 1–255 printable ASCII characters |
| 422 | idempotency_key_reused |
The same Idempotency-Key was used for a request with different content |
| 503 | automation_dispatch_failed |
Starting the immediate run failed. The registered automations become failed. Resending with the same Idempotency-Key registers them again and starts them |
curl -X POST "https://stepeee.com/public-api/v1/automations" \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90" \
-d '{
"variation_uniques": ["Ab3dEf6hIj9k", "Lm2nOp5qRs8t"],
"automation_template_unique": "Tm7pL2xQ9z",
"reserved_at": "2030-01-02T12:04:05+09:00"
}'
import requests
body = """{
"variation_uniques": ["Ab3dEf6hIj9k", "Lm2nOp5qRs8t"],
"automation_template_unique": "Tm7pL2xQ9z",
"reserved_at": "2030-01-02T12:04:05+09:00"
}"""
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/automations",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
data=body,
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/automations", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
body: JSON.stringify({
"variation_uniques": ["Ab3dEf6hIj9k", "Lm2nOp5qRs8t"],
"automation_template_unique": "Tm7pL2xQ9z",
"reserved_at": "2030-01-02T12:04:05+09:00"
}),
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/automations")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90", forHTTPHeaderField: "Idempotency-Key")
request.httpBody = """
{
"variation_uniques": ["Ab3dEf6hIj9k", "Lm2nOp5qRs8t"],
"automation_template_unique": "Tm7pL2xQ9z",
"reserved_at": "2030-01-02T12:04:05+09:00"
}
""".data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/automations"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"variation_uniques": ["Ab3dEf6hIj9k", "Lm2nOp5qRs8t"],
"automation_template_unique": "Tm7pL2xQ9z",
"reserved_at": "2030-01-02T12:04:05+09:00"
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$body = <<<'JSON'
{
"variation_uniques": ["Ab3dEf6hIj9k", "Lm2nOp5qRs8t"],
"automation_template_unique": "Tm7pL2xQ9z",
"reserved_at": "2030-01-02T12:04:05+09:00"
}
JSON;
$ch = curl_init("https://stepeee.com/public-api/v1/automations");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type: application/json",
"Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"auto_unique": "Qw3eRt6yUi",
"mode": "blogmode",
"project": { "project_unique": "Pj8kL3mN2b", "project_name": "My Project" },
"content": { "variation_unique": "Ab3dEf6hIj9k", "title": "My blog title" },
"run_no": 1,
"status": "pending",
"execution_mode": "sequential",
"phases": { "setup": "running", "step1": "not_started", "step2": "not_started", "step3": "not_started" },
"template": { "setup_template_name": null, "automation_template_name": "Morning blog" },
"reserved_at": "2030-01-02T03:04:05Z",
"executed_at": null,
"completed_at": null,
"created_at": "2026-10-21T10:00:00Z"
},
{
"auto_unique": "As4dFg7hJk",
"mode": "blogmode",
"project": { "project_unique": "Pj8kL3mN2b", "project_name": "My Project" },
"content": { "variation_unique": "Lm2nOp5qRs8t", "title": "My blog title" },
"run_no": 1,
"status": "pending",
"execution_mode": "sequential",
"phases": { "setup": "running", "step1": "not_started", "step2": "not_started", "step3": "not_started" },
"template": { "setup_template_name": null, "automation_template_name": "Morning blog" },
"reserved_at": "2030-01-02T03:04:05Z",
"executed_at": null,
"completed_at": null,
"created_at": "2026-10-21T10:00:00Z"
}
]
}
Change the scheduled time
Changes the run time of an automation waiting to run (status pending). Automations that have started or finished cannot be changed.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
auto_unique |
Path | string | Required | — | The automation run ID |
reserved_at |
Body | string | Required | — | The new run time (a future date and time). Specify it in ISO 8601 with a time offset; values without an offset are treated as UTC |
| Field | Type | Description |
|---|---|---|
auto_unique |
string | The automation run ID |
mode |
string | moviemode / blogmode / carouselmode |
project |
object | The project {project_unique, project_name} |
content.variation_unique |
string | The content ID |
content.title |
string | null | The title (the concept name for postmode) |
run_no |
integer | Which run this is for the content |
status |
string | The status. pending (waiting to run; scheduled when reserved_at is in the future) / executing / waiting_confirmation (waiting for content review; continues after review) / completed / failed / stopped / token_shortage (ended because tokens ran out) |
execution_mode |
string | sequential (one at a time) / parallel (all at once) |
phases |
object | The status of each stage {setup, step1, step2, step3}. not_started / running (running, or the stage where it stopped) / done (including skipped, and, for a run that finished as completed, steps that were out of this run's scope) / error |
template.setup_template_name |
string | null | The name of the setup template used at the start |
template.automation_template_name |
string | null | The name of the automation template used at the start |
reserved_at |
string | null | The scheduled run time |
executed_at |
string | null | When the run started |
completed_at |
string | null | When the run ended (for completed / failed / stopped / token_shortage) |
created_at |
string | When the run was registered |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 404 | not_found |
The automation does not exist or is outside what you can access (Automation not found.) |
| 409 | automation_not_pending |
Not waiting to run (pending); it has started or finished |
| 422 | validation_error |
reserved_at is missing, cannot be read as a date and time, or is in the past |
curl -X PATCH "https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi" \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"reserved_at": "2031-05-06T07:08:09+09:00"
}'
import requests
body = """{
"reserved_at": "2031-05-06T07:08:09+09:00"
}"""
response = requests.request(
"PATCH",
"https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
},
data=body,
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi", {
method: "PATCH",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"reserved_at": "2031-05-06T07:08:09+09:00"
}),
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi")!)
request.httpMethod = "PATCH"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = """
{
"reserved_at": "2031-05-06T07:08:09+09:00"
}
""".data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString("""
{
"reserved_at": "2031-05-06T07:08:09+09:00"
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$body = <<<'JSON'
{
"reserved_at": "2031-05-06T07:08:09+09:00"
}
JSON;
$ch = curl_init("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"auto_unique": "Qw3eRt6yUi",
"mode": "blogmode",
"project": { "project_unique": "Pj8kL3mN2b", "project_name": "My Project" },
"content": { "variation_unique": "Ab3dEf6hIj9k", "title": "My blog title" },
"run_no": 1,
"status": "pending",
"execution_mode": "sequential",
"phases": { "setup": "running", "step1": "not_started", "step2": "not_started", "step3": "not_started" },
"template": { "setup_template_name": null, "automation_template_name": "Morning blog" },
"reserved_at": "2031-05-05T22:08:09Z",
"executed_at": null,
"completed_at": null,
"created_at": "2026-10-21T10:00:00Z"
}
}
Stop an automation
The same action as stopping in the app. Available on any plan (so you can stop it even after changing your plan). Automations waiting to run (including scheduled ones) or waiting for review become stopped immediately. For a running automation, a stop is requested: at the time of the response it is still executing, and it becomes stopped at the next break between steps (check with GET /automations/{auto_unique}). Finished automations are left as they are and their current state is returned.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
auto_unique |
Path | string | Required | — | The automation run ID |
| Field | Type | Description |
|---|---|---|
auto_unique |
string | The automation run ID |
mode |
string | moviemode / blogmode / carouselmode |
project |
object | The project {project_unique, project_name} |
content.variation_unique |
string | The content ID |
content.title |
string | null | The title (the concept name for postmode) |
run_no |
integer | Which run this is for the content |
status |
string | The status. pending (waiting to run; scheduled when reserved_at is in the future) / executing / waiting_confirmation (waiting for content review; continues after review) / completed / failed / stopped / token_shortage (ended because tokens ran out) |
execution_mode |
string | sequential (one at a time) / parallel (all at once) |
phases |
object | The status of each stage {setup, step1, step2, step3}. not_started / running (running, or the stage where it stopped) / done (including skipped, and, for a run that finished as completed, steps that were out of this run's scope) / error |
template.setup_template_name |
string | null | The name of the setup template used at the start |
template.automation_template_name |
string | null | The name of the automation template used at the start |
reserved_at |
string | null | The scheduled run time |
executed_at |
string | null | When the run started |
completed_at |
string | null | When the run ended (for completed / failed / stopped / token_shortage) |
created_at |
string | When the run was registered |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The automation does not exist or is outside what you can access (Automation not found.) |
curl -X POST "https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/stop" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/stop",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/stop", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/stop")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/stop"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.method("POST", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/stop");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"auto_unique": "Qw3eRt6yUi",
"mode": "blogmode",
"project": { "project_unique": "Pj8kL3mN2b", "project_name": "My Project" },
"content": { "variation_unique": "Ab3dEf6hIj9k", "title": "My blog title" },
"run_no": 1,
"status": "stopped",
"execution_mode": "sequential",
"phases": { "setup": "running", "step1": "not_started", "step2": "not_started", "step3": "not_started" },
"template": { "setup_template_name": null, "automation_template_name": "Morning blog" },
"reserved_at": "2030-01-02T03:04:05Z",
"executed_at": null,
"completed_at": "2026-10-21T10:05:00Z",
"created_at": "2026-10-21T10:00:00Z"
}
}
Approve an automation waiting for review
For an automation stopped waiting for content review (status waiting_confirmation), marks the content as reviewed and runs the rest (creating social media text, posting, and so on). The same action as reviewing the content in the app. On success the automation returns to pending and starts running right away.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
auto_unique |
Path | string | Required | — | The automation run ID |
Idempotency-Key |
Header | string | Optional | — | A unique string you generate for each action (1–255 printable ASCII characters). Resending the same content with the same key returns the first response as is (see Idempotency-Key in the conventions) |
| Field | Type | Description |
|---|---|---|
auto_unique |
string | The automation run ID |
mode |
string | moviemode / blogmode / carouselmode |
project |
object | The project {project_unique, project_name} |
content.variation_unique |
string | The content ID |
content.title |
string | null | The title (the concept name for postmode) |
run_no |
integer | Which run this is for the content |
status |
string | The status. pending (waiting to run; scheduled when reserved_at is in the future) / executing / waiting_confirmation (waiting for content review; continues after review) / completed / failed / stopped / token_shortage (ended because tokens ran out) |
execution_mode |
string | sequential (one at a time) / parallel (all at once) |
phases |
object | The status of each stage {setup, step1, step2, step3}. not_started / running (running, or the stage where it stopped) / done (including skipped, and, for a run that finished as completed, steps that were out of this run's scope) / error |
template.setup_template_name |
string | null | The name of the setup template used at the start |
template.automation_template_name |
string | null | The name of the automation template used at the start |
reserved_at |
string | null | The scheduled run time |
executed_at |
string | null | When the run started |
completed_at |
string | null | When the run ended (for completed / failed / stopped / token_shortage) |
created_at |
string | When the run was registered |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 404 | not_found |
The automation does not exist or is outside what you can access (Automation not found.) |
| 409 | automation_not_waiting_confirmation |
Not waiting for review (waiting_confirmation) |
| 409 | content_readonly |
The content is read-only because of a plan downgrade |
| 409 | idempotency_request_in_progress |
A request with the same Idempotency-Key is still being processed |
| 422 | idempotency_key_invalid |
The Idempotency-Key is not 1–255 printable ASCII characters |
| 422 | idempotency_key_reused |
The same Idempotency-Key was used for a request with different content |
| 503 | automation_dispatch_failed |
Starting the rest of the run failed. The automation becomes failed (the content stays reviewed, so start a new automation to continue) |
curl -X POST "https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/confirm" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \ -H "Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90"
import requests
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/confirm",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/confirm", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/confirm")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90", forHTTPHeaderField: "Idempotency-Key")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/confirm"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Idempotency-Key", "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90")
.method("POST", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/automations/Qw3eRt6yUi/confirm");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"auto_unique": "Qw3eRt6yUi",
"mode": "carouselmode",
"project": { "project_unique": "Pj8kL3mN2b", "project_name": "My Project" },
"content": { "variation_unique": "Ab3dEf6hIj9k", "title": "My carousel title" },
"run_no": 1,
"status": "pending",
"execution_mode": "parallel",
"phases": { "setup": "done", "step1": "done", "step2": "done", "step3": "running" },
"template": { "setup_template_name": null, "automation_template_name": "Weekly carousel" },
"reserved_at": null,
"executed_at": "2026-10-21T09:00:00Z",
"completed_at": null,
"created_at": "2026-10-21T09:00:00Z"
}
}
Post review
For post (post mode) automation content with pre-posting review set to required or optional, approve, post now, reschedule, or cancel posts. The targets are the same posts as in the app's review list and the links in review emails, and the same rules apply. The list is available on any plan; actions require the Pro plan or higher.
-
Posts awaiting review
GET /post-confirms -
Approve a post
POST /post-schedules/{schedule_unique}/approve -
Post an expired post now
POST /post-schedules/{schedule_unique}/post-now -
Reschedule an expired post
PATCH /post-schedules/{schedule_unique} -
Cancel a post
DELETE /post-schedules/{schedule_unique}
Posts awaiting review
Gets posts for which a review email was sent that are before their scheduled time (upcoming) or expired (expired), in order of scheduled time. upcoming posts are those awaiting approval (awaiting; not posted unless approved) and notice-only posts (notice; posted as is). expired posts are posts that passed their scheduled time without approval and were not posted; they are returned for only 30 days after the scheduled time. A thread is returned as one item for its first post, with the second and later posts in thread. Available on any plan.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
state |
Query | string | Optional | — | Filter by upcoming (before the scheduled time) or expired. Both if omitted |
project_unique |
Query | string | Optional | — | Return only posts of this project |
page |
Query | integer | Optional | 1 |
The page to return (starting at 1) |
per_page |
Query | integer | Optional | 20 |
The number of items per page (1–100) |
| Field | Type | Description |
|---|---|---|
data[].schedule_unique |
string | The ID of the scheduled post. Used in the action paths |
data[].state |
string | null | upcoming (before the scheduled time) / expired. null for posts no longer under review after approval, rescheduling, and so on (only in action responses) |
data[].confirm_state |
string | awaiting (awaiting approval; not posted until approved) / notice (notice only; posted as is) / confirmed (approved) / none (no review) |
data[].confirm_mode |
string | The content's review setting. required (not posted until approved) / optional (posted even without approval) / none (no review) |
data[].can_approve |
boolean | Whether it can be approved (true when upcoming and awaiting) |
data[].can_post_now |
boolean | Whether it can be posted now (true when expired) |
data[].can_reschedule |
boolean | Whether it can be rescheduled (true when expired) |
data[].scheduled_at |
string | When the post is scheduled |
data[].scheduled_at_local |
string | The scheduled time in the local time of timezone (YYYY-MM-DDTHH:MM:SS) |
data[].timezone |
string | The time zone when the plan was created |
data[].deadline_at |
string | null | The deadline for actions. For upcoming, the scheduled time (after which it becomes expired); for expired, 30 days after the scheduled time (after which it disappears from the list and cannot be acted on). null if state is null |
data[].project |
object | The project {project_unique, project_name} |
data[].concept |
object | The concept {concept_unique, concept_name} |
data[].variation_unique |
string | The content ID |
data[].post_unique |
string | The post ID (the same as items[].post_unique in the posting schedule) |
data[].post_type |
string | The post type. assertion (opinion) / knowhow / experience / casual (everyday) |
data[].text |
string | The post text (the first post of a thread) |
data[].thread[] |
object | The second and later posts of a thread {text, image} (in order; an empty array if not a thread) |
data[].image |
object | null | The attached image {url, expires_at} (a signed URL that expires in 1 hour). null if none |
meta.total |
integer | The total number of matching items |
meta.current_page |
integer | The current page |
meta.per_page |
integer | The number of items per page |
meta.last_page |
integer | The last page |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The project does not exist or is outside what you can access (Project not found.) |
| 422 | validation_error |
The parameters are invalid |
curl "https://stepeee.com/public-api/v1/post-confirms?state=upcoming" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/post-confirms?state=upcoming",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/post-confirms?state=upcoming", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/post-confirms?state=upcoming")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/post-confirms?state=upcoming"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/post-confirms?state=upcoming");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"schedule_unique": "aB3dE5fG7hJ9",
"state": "upcoming",
"confirm_state": "awaiting",
"confirm_mode": "required",
"can_approve": true,
"can_post_now": false,
"can_reschedule": false,
"scheduled_at": "2026-10-19T13:00:00Z",
"scheduled_at_local": "2026-10-19T22:00:00",
"timezone": "Asia/Tokyo",
"deadline_at": "2026-10-19T13:00:00Z",
"project": { "project_unique": "Pq7Rs9Tu1V", "project_name": "My brand" },
"concept": { "concept_unique": "Cx2Yz4Wv6Ut8", "concept_name": "Morning tips" },
"variation_unique": "Vr5Ws7Xt9Yu1",
"post_unique": "Ps3Qt5Ru7Sv9",
"post_type": "knowhow",
"text": "First post of the thread",
"thread": [
{ "text": "Second post", "image": null },
{ "text": "Third post", "image": { "url": "https://...", "expires_at": "2026-10-19T13:00:00Z" } }
],
"image": null
}
],
"meta": { "total": 1, "current_page": 1, "per_page": 20, "last_page": 1 }
}
Approve a post
Approves a post awaiting approval (upcoming with confirm_state awaiting). Approved posts are posted at their scheduled time. For posts past their scheduled time, use post now or reschedule.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
schedule_unique |
Path | string | Required | — | The ID of the scheduled post (schedule_unique from GET /post-confirms) |
Idempotency-Key |
Header | string | Optional | — | A unique string you generate for each action (1–255 printable ASCII characters). Resending the same content with the same key returns the first response as is (see Idempotency-Key in the conventions) |
| Field | Type | Description |
|---|---|---|
schedule_unique / state / … / image |
— | The same as one item in the list of posts awaiting review (the state after the action) |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 404 | post_schedule_not_found |
A post that does not appear in GET /post-confirms (does not exist, outside your access, not under review, or already approved) |
| 409 | confirm_expired |
Past the scheduled time (use post now or reschedule) |
| 409 | confirm_not_pending |
Not awaiting approval (for example, a notice-only post) |
| 409 | idempotency_request_in_progress |
A request with the same Idempotency-Key is still being processed |
| 422 | idempotency_key_invalid |
The Idempotency-Key is not 1–255 printable ASCII characters |
| 422 | idempotency_key_reused |
The same Idempotency-Key was used for a request with different content |
curl -X POST "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/approve" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \ -H "Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90"
import requests
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/approve",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/approve", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/approve")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90", forHTTPHeaderField: "Idempotency-Key")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/approve"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Idempotency-Key", "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90")
.method("POST", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/approve");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"schedule_unique": "aB3dE5fG7hJ9",
"state": null,
"confirm_state": "confirmed",
"confirm_mode": "required",
"can_approve": false,
"can_post_now": false,
"can_reschedule": false,
"scheduled_at": "2026-10-19T13:00:00Z",
"scheduled_at_local": "2026-10-19T22:00:00",
"timezone": "Asia/Tokyo",
"deadline_at": null,
"project": { "project_unique": "Pq7Rs9Tu1V", "project_name": "My brand" },
"concept": { "concept_unique": "Cx2Yz4Wv6Ut8", "concept_name": "Morning tips" },
"variation_unique": "Vr5Ws7Xt9Yu1",
"post_unique": "Ps3Qt5Ru7Sv9",
"post_type": "knowhow",
"text": "First post of the thread",
"thread": [
{ "text": "Second post", "image": null },
{ "text": "Third post", "image": { "url": "https://...", "expires_at": "2026-10-19T13:00:00Z" } }
],
"image": null
}
}
Post an expired post now
Sets an expired post to be posted 1 minute from now and marks it approved (it will be posted shortly). The second and later posts of a thread are shifted by the same amount. Can only be used for expired posts.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
schedule_unique |
Path | string | Required | — | The ID of the scheduled post (schedule_unique from GET /post-confirms) |
allow_close_slot |
Body | boolean | Optional | false |
true to proceed even if the gap to other scheduled posts of the same content becomes shorter than the minimum gap (90 minutes) |
Idempotency-Key |
Header | string | Optional | — | A unique string you generate for each action (1–255 printable ASCII characters). Resending the same content with the same key returns the first response as is (see Idempotency-Key in the conventions) |
| Field | Type | Description |
|---|---|---|
schedule_unique / state / … / image |
— | The same as one item in the list of posts awaiting review (the state after the action) |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 403 | resource_readonly |
The content is read-only because of a plan downgrade |
| 404 | post_schedule_not_found |
A post that does not appear in GET /post-confirms (does not exist, outside your access, not under review, or already approved) |
| 409 | confirm_not_expired |
Not expired (use approve before the scheduled time) |
| 409 | schedule_close_to_other_slot |
allow_close_slot is false and the gap to another scheduled post of the same content would be shorter than 90 minutes. details contains min_gap_minutes (the minimum gap in minutes), timezone, and conflicts (an array of nearby scheduled posts {scheduled_at_local, minutes_apart}) |
| 409 | schedule_locked |
Posting or posted, or within 2 minutes of the scheduled time where it overlaps with posting |
| 409 | schedule_processing |
The action happened at the same time as posting and could not be applied |
| 409 | idempotency_request_in_progress |
A request with the same Idempotency-Key is still being processed |
| 422 | validation_error |
allow_close_slot is not true or false |
| 422 | idempotency_key_invalid |
The Idempotency-Key is not 1–255 printable ASCII characters |
| 422 | idempotency_key_reused |
The same Idempotency-Key was used for a request with different content |
curl -X POST "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/post-now" \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90" \
-d '{
"allow_close_slot": true
}'
import requests
body = """{
"allow_close_slot": true
}"""
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/post-now",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
data=body,
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/post-now", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
body: JSON.stringify({
"allow_close_slot": true
}),
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/post-now")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90", forHTTPHeaderField: "Idempotency-Key")
request.httpBody = """
{
"allow_close_slot": true
}
""".data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/post-now"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"allow_close_slot": true
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$body = <<<'JSON'
{
"allow_close_slot": true
}
JSON;
$ch = curl_init("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9/post-now");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type: application/json",
"Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"schedule_unique": "aB3dE5fG7hJ9",
"state": null,
"confirm_state": "confirmed",
"confirm_mode": "required",
"can_approve": false,
"can_post_now": false,
"can_reschedule": false,
"scheduled_at": "2026-10-21T12:01:00Z",
"scheduled_at_local": "2026-10-21T21:01:00",
"timezone": "Asia/Tokyo",
"deadline_at": null,
"project": { "project_unique": "Pq7Rs9Tu1V", "project_name": "My brand" },
"concept": { "concept_unique": "Cx2Yz4Wv6Ut8", "concept_name": "Morning tips" },
"variation_unique": "Vr5Ws7Xt9Yu1",
"post_unique": "Ps3Qt5Ru7Sv9",
"post_type": "knowhow",
"text": "First post of the thread",
"thread": [
{ "text": "Second post", "image": null },
{ "text": "Third post", "image": { "url": "https://...", "expires_at": "2026-10-19T13:00:00Z" } }
],
"image": null
}
}
Reschedule an expired post
Changes the scheduled time of an expired post and marks it approved (it will be posted at the specified time). The second and later posts of a thread are shifted by the same amount. Only expired posts can be rescheduled.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
schedule_unique |
Path | string | Required | — | The ID of the scheduled post (schedule_unique from GET /post-confirms) |
scheduled_at |
Body | string | Required | — | The new scheduled time (a future date and time). Specify it in ISO 8601 with a time offset (such as 2026-10-21T09:30:00+09:00 or 2026-10-21T00:30:00Z; seconds can be omitted and fractions of a second are truncated). It is converted to the time in the post's timezone and saved |
allow_close_slot |
Body | boolean | Optional | false |
true to proceed even if the gap to other scheduled posts of the same content becomes shorter than the minimum gap (90 minutes) |
| Field | Type | Description |
|---|---|---|
schedule_unique / state / … / image |
— | The same as one item in the list of posts awaiting review (the state after the action) |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 403 | resource_readonly |
The content is read-only because of a plan downgrade |
| 404 | post_schedule_not_found |
A post that does not appear in GET /post-confirms (does not exist, outside your access, not under review, or already approved) |
| 409 | confirm_not_expired |
Not expired (use approve before the scheduled time) |
| 409 | schedule_close_to_other_slot |
allow_close_slot is false and the gap to another scheduled post of the same content would be shorter than 90 minutes. details contains min_gap_minutes (the minimum gap in minutes), timezone, and conflicts (an array of nearby scheduled posts {scheduled_at_local, minutes_apart}) |
| 409 | schedule_locked |
Posting or posted, or within 2 minutes of the scheduled time where it overlaps with posting |
| 409 | schedule_processing |
The action happened at the same time as posting and could not be applied |
| 422 | confirm_reschedule_past |
scheduled_at is at or before the current time |
| 422 | validation_error |
scheduled_at is missing, has no time offset, or cannot be read as a date and time, or allow_close_slot is not true or false |
curl -X PATCH "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9" \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"scheduled_at": "2026-10-22T09:30:00-05:00",
"allow_close_slot": false
}'
import requests
body = """{
"scheduled_at": "2026-10-22T09:30:00-05:00",
"allow_close_slot": false
}"""
response = requests.request(
"PATCH",
"https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
},
data=body,
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9", {
method: "PATCH",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"scheduled_at": "2026-10-22T09:30:00-05:00",
"allow_close_slot": false
}),
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9")!)
request.httpMethod = "PATCH"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = """
{
"scheduled_at": "2026-10-22T09:30:00-05:00",
"allow_close_slot": false
}
""".data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString("""
{
"scheduled_at": "2026-10-22T09:30:00-05:00",
"allow_close_slot": false
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$body = <<<'JSON'
{
"scheduled_at": "2026-10-22T09:30:00-05:00",
"allow_close_slot": false
}
JSON;
$ch = curl_init("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"schedule_unique": "aB3dE5fG7hJ9",
"state": null,
"confirm_state": "confirmed",
"confirm_mode": "required",
"can_approve": false,
"can_post_now": false,
"can_reschedule": false,
"scheduled_at": "2026-10-22T14:30:00Z",
"scheduled_at_local": "2026-10-22T23:30:00",
"timezone": "Asia/Tokyo",
"deadline_at": null,
"project": { "project_unique": "Pq7Rs9Tu1V", "project_name": "My brand" },
"concept": { "concept_unique": "Cx2Yz4Wv6Ut8", "concept_name": "Morning tips" },
"variation_unique": "Vr5Ws7Xt9Yu1",
"post_unique": "Ps3Qt5Ru7Sv9",
"post_type": "knowhow",
"text": "First post of the thread",
"thread": [
{ "text": "Second post", "image": null },
{ "text": "Third post", "image": { "url": "https://...", "expires_at": "2026-10-19T13:00:00Z" } }
],
"image": null
}
}
Cancel a post
Cancels a post (whether before its scheduled time or expired). The post, the second and later posts of the thread, and the schedule are all deleted. This cannot be undone.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
schedule_unique |
Path | string | Required | — | The ID of the scheduled post (schedule_unique from GET /post-confirms) |
| Field | Type | Description |
|---|---|---|
schedule_unique |
string | The ID of the canceled scheduled post |
deleted |
boolean | Always true |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 403 | resource_readonly |
The content is read-only because of a plan downgrade |
| 404 | post_schedule_not_found |
A post that does not appear in GET /post-confirms (does not exist, outside your access, not under review, or already approved) |
| 409 | post_posting_locked |
Being posted (including the second and later posts of the thread) and cannot be canceled |
curl -X DELETE "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.request(
"DELETE",
"https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9", {
method: "DELETE",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9")!)
request.httpMethod = "DELETE"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.method("DELETE", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/post-schedules/aB3dE5fG7hJ9");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"schedule_unique": "aB3dE5fG7hJ9",
"deleted": true
}
}
Webhooks
Sends automation and posting events as JSON to the URLs you register. Use it to learn about completions, failures, and reviews without calling the API repeatedly. Registering and test sends require the Pro plan or higher; listing and deleting are available on any plan.
-
List webhook endpoints
GET /webhooks -
Register a webhook endpoint
POST /webhooks -
Delete a webhook endpoint
DELETE /webhooks/{webhook_unique} -
Test a send
POST /webhooks/{webhook_unique}/test
List webhook endpoints
Gets your registered endpoints in the order they were registered (up to 5; no paging). The signing secret is not included. Available on any plan.
| Field | Type | Description |
|---|---|---|
data[].webhook_unique |
string | The endpoint ID |
data[].url |
string | The destination URL |
data[].events |
string[] | The event names to receive |
data[].description |
string | null | A note |
data[].is_active |
boolean | Whether events are currently sent. true when the owner is on the Pro plan or higher (for team members, when the team owner is on the Team plan). Events that happen while false are not sent |
data[].last_delivery_at |
string | null | When the most recent send was attempted (test sends are not included) |
data[].last_delivery_status |
string | null | The result of the most recent send. succeeded / failed. null if nothing has been sent yet |
data[].last_response_status |
integer | null | The HTTP status received in the most recent send (null if there was no response) |
data[].last_error |
string | null | Why the most recent send failed. The values are the same as error of the test send |
data[].created_at |
string | Created at |
curl "https://stepeee.com/public-api/v1/webhooks" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.get(
"https://stepeee.com/public-api/v1/webhooks",
headers={"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx"},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/webhooks", {
headers: { Authorization: "Bearer sk_xxxxxxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/webhooks")!)
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/webhooks"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/webhooks");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": [
{
"webhook_unique": "Xk3pQ9vLm2Ta",
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook",
"is_active": true,
"last_delivery_at": "2026-10-21T12:01:00Z",
"last_delivery_status": "failed",
"last_response_status": 503,
"last_error": "non_2xx_response",
"created_at": "2026-10-20T09:00:00Z"
}
]
}
Register a webhook endpoint
Registers an endpoint. The secret (signing secret) in the response is returned only in this response, and there is no way to show it later. If you lose it, delete the endpoint and register it again. You can register up to 5 endpoints. Events that happened before registration are not sent.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
url |
Body | string | Required | — | The destination URL (https only, port 443 only (can be omitted), up to 2048 characters). URLs containing a user name or password cannot be registered. URLs that resolve to internal or reserved addresses (private IPs, loopback, link-local, localhost, and so on) and URLs whose name cannot be resolved cannot be registered |
events |
Body | string[] | Required | — | The event names to receive (one or more from the event list, no duplicates) |
description |
Body | string | null | Optional | — | A note (up to 100 characters) |
Idempotency-Key |
Header | string | Optional | — | A unique string you generate for each action (1–255 printable ASCII characters). Resending the same content with the same key returns the first response as is (see Idempotency-Key in the conventions) |
| Field | Type | Description |
|---|---|---|
webhook_unique |
string | The endpoint ID |
url |
string | The destination URL |
events |
string[] | The event names to receive |
description |
string | null | A note |
is_active |
boolean | Whether events are currently sent. true when the owner is on the Pro plan or higher (for team members, when the team owner is on the Team plan). Events that happen while false are not sent |
last_delivery_at |
string | null | When the most recent send was attempted (test sends are not included) |
last_delivery_status |
string | null | The result of the most recent send. succeeded / failed. null if nothing has been sent yet |
last_response_status |
integer | null | The HTTP status received in the most recent send (null if there was no response) |
last_error |
string | null | Why the most recent send failed. The values are the same as error of the test send |
created_at |
string | Created at |
secret |
string | The signing secret (starts with whsec_). Returned only in this response |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 409 | webhook_limit_reached |
You have already registered 5 |
| 409 | idempotency_request_in_progress |
A request with the same Idempotency-Key is still being processed |
| 422 | validation_error |
The input is invalid (a URL that is not https, a port other than 443, a URL containing a user name or password, a URL that resolves to an internal address or whose name cannot be resolved, an unknown event name, duplicate event names, and so on) |
| 422 | idempotency_key_invalid |
The Idempotency-Key is not 1–255 printable ASCII characters |
| 422 | idempotency_key_reused |
The same Idempotency-Key was used for a request with different content |
curl -X POST "https://stepeee.com/public-api/v1/webhooks" \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90" \
-d '{
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook"
}'
import requests
body = """{
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook"
}"""
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/webhooks",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
data=body,
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/webhooks", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
},
body: JSON.stringify({
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook"
}),
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/webhooks")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90", forHTTPHeaderField: "Idempotency-Key")
request.httpBody = """
{
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook"
}
""".data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/webhooks"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.header("Content-Type", "application/json")
.header("Idempotency-Key", "5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook"
}
"""))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$body = <<<'JSON'
{
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook"
}
JSON;
$ch = curl_init("https://stepeee.com/public-api/v1/webhooks");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
"Content-Type: application/json",
"Idempotency-Key: 5f0c2d8e-7a41-4c1b-9d3e-2b8f6a1c4e90",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"webhook_unique": "Xk3pQ9vLm2Ta",
"url": "https://example.com/hooks/stepeee",
"events": ["automation.completed", "post.published"],
"description": "production hook",
"is_active": true,
"last_delivery_at": null,
"last_delivery_status": null,
"last_response_status": null,
"last_error": null,
"created_at": "2026-10-21T12:00:00Z",
"secret": "whsec_5f0c2a9e8b7d4c3a1f6e9d8c7b6a5f4e3d2c1b0a9f8e7d6c"
}
}
Delete a webhook endpoint
Deletes an endpoint. Events not yet sent and events waiting to be retried are not sent either. Available on any plan.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
webhook_unique |
Path | string | Required | — | The endpoint ID |
| Field | Type | Description |
|---|---|---|
webhook_unique |
string | The ID of the deleted endpoint |
deleted |
boolean | Always true |
| Status | Code | When |
|---|---|---|
| 404 | not_found |
The endpoint does not exist or belongs to another user |
curl -X DELETE "https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.request(
"DELETE",
"https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta", {
method: "DELETE",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta")!)
request.httpMethod = "DELETE"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.method("DELETE", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"webhook_unique": "Xk3pQ9vLm2Ta",
"deleted": true
}
}
Test a send
Sends a webhook.test event once on the spot and returns the result. It is sent with the same signature, headers, and destination checks as real events, so you can use it to check signature verification on the receiving side. It is not retried and is not recorded in last_delivery_at and similar fields.
| Name | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
webhook_unique |
Path | string | Required | — | The endpoint ID |
| Field | Type | Description |
|---|---|---|
delivered |
boolean | Whether a 2xx response was received |
response_status |
integer | null | The HTTP status received (null if there was no response) |
error |
string | null | Why it failed. url_blocked (not https, a port other than 443, contains a user name and password, or just before sending it resolved to an internal address or could not be resolved) / timeout (no response within 5 seconds) / connection_failed (the connection or TLS failed) / non_2xx_response (a response other than 2xx; redirects are also treated as failures and are not followed). null on success |
| Status | Code | When |
|---|---|---|
| 403 | plan_required |
The API key owner is below the Pro plan |
| 403 | team_plan_lost |
You are a team member and the team owner is no longer on the Team plan |
| 404 | not_found |
The endpoint does not exist or belongs to another user |
curl -X POST "https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta/test" \ -H "Authorization: Bearer sk_xxxxxxxxxxxxxxxx"
import requests
response = requests.request(
"POST",
"https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta/test",
headers={
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
)
print(response.json())
const response = await fetch("https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta/test", {
method: "POST",
headers: {
"Authorization": "Bearer sk_xxxxxxxxxxxxxxxx",
},
});
const data = await response.json();
console.log(data);
var request = URLRequest(url: URL(string: "https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta/test")!)
request.httpMethod = "POST"
request.setValue("Bearer sk_xxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
print(String(data: data, encoding: .utf8)!)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta/test"))
.header("Authorization", "Bearer sk_xxxxxxxxxxxxxxxx")
.method("POST", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init("https://stepeee.com/public-api/v1/webhooks/Xk3pQ9vLm2Ta/test");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer sk_xxxxxxxxxxxxxxxx",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
"data": {
"delivered": false,
"response_status": 500,
"error": "non_2xx_response"
}
}
Events
When an event you registered for occurs, a POST is sent to the registered URL. Only events in projects the endpoint owner can access at that time are sent. Events are checked every minute, so delivery can take a few minutes. Events that happened before the endpoint was registered, and events that happened while the owner was below the Pro plan (for team members, while the team owner was not on the Team plan), are not sent.
| Event | Meaning |
|---|---|
automation.waiting_confirmation | An automation (video, blog, or carousel) stopped waiting for content review |
automation.completed | An automation completed to the end |
automation.failed | An automation ended with failed, stopped, or token_shortage (ran out of Token) |
post.published | Posted to a social network (a video, blog, or carousel posting result became success, or a post (post mode) was posted to X). Both manual and automated posts are included |
post.failed | A post failed (a video, blog, or carousel posting result became failed or partial_success, or a post (post mode) was skipped because posting or text creation failed) |
post.confirm_required | A post (post mode) awaiting approval appeared in GET /post-confirms (it is not posted until approved) |
webhook.test | A test send (POST /webhooks/{webhook_unique}/test). It cannot be used as an event to register for |
Request body
The body is JSON (UTF-8) of {id, type, created_at, data}. id is the event ID (starts with evt_), type is the event name, and created_at is when the event was recorded. When the same event is resent, the body is the same and only t in the signature changes. The shape of data for each event is as follows.
| Event | Shape of data |
|---|---|
automation.* | The same shape as one item of data[] in GET /automations. status is waiting_confirmation for automation.waiting_confirmation, completed for automation.completed, and failed / stopped / token_shortage for automation.failed |
post.publishedpost.failed | Video, blog, and carousel: one item of data[] in GET /projects/{project_unique}/publications plus project. For post.failed, status is failed or partial_success, and error_code contains the same failure code as the posting result. Posts (post mode): the same shape plus schedule_unique. publication_unique is the post ID (null for a schedule whose text creation failed), content.title is the concept name, account is null, and error_code is token_expired / api_error / not_found / insufficient_token / no_x_account / node_ws_timeout. Use is_automation to tell whether it was an automated post |
post.confirm_required | The same shape as one item of data[] in GET /post-confirms, with the values at the time the event was recorded. Not sent for notice-only (notice) posts. The signed image URL expires 1 hour after recording, so if it arrives late, get it again with GET /post-confirms. If a post for the same schedule is recreated, it arrives again with a new post_unique |
webhook.test | {"webhook_unique": "…"} |
Body examples
automation.failed{
"id": "evt_Q2x8Lr0aZk9MvB3pT7cYw1Nd",
"type": "automation.failed",
"created_at": "2026-10-21T12:01:03Z",
"data": {
"auto_unique": "a8Kd02LmQz",
"mode": "blogmode",
"project": { "project_unique": "Pj7Xc2Lq9W", "project_name": "My Project" },
"content": { "variation_unique": "Vb3Nq8Ls1Kd0", "title": "How to start" },
"run_no": 2,
"status": "token_shortage",
"execution_mode": "sequential",
"phases": { "setup": "done", "step1": "done", "step2": "running", "step3": "not_started" },
"template": { "setup_template_name": "Default", "automation_template_name": null },
"reserved_at": null,
"executed_at": "2026-10-21T11:40:00Z",
"completed_at": "2026-10-21T12:00:41Z",
"created_at": "2026-10-21T11:39:58Z"
}
}
post.published{
"id": "evt_7hT2mWq9Zp3LxR8cVb1Nk5Ds",
"type": "post.published",
"created_at": "2026-10-21T12:02:10Z",
"data": {
"publication_unique": "Lg4Hs9Pq2Wx7",
"mode": "moviemode",
"project": { "project_unique": "Pj7Xc2Lq9W", "project_name": "My Project" },
"content": { "variation_unique": "Mv8Kq2Zt5Rb1", "title": "5 tips" },
"platform": "youtube",
"account": { "account_unique": "Ac1Bd2Ef3Gh4", "name": "My Channel" },
"status": "success",
"post_url": "https://www.youtube.com/watch?v=xxxxxxxx",
"error_code": null,
"is_automation": true,
"posted_at": "2026-10-21T12:01:55Z",
"created_at": "2026-10-21T12:01:20Z"
}
}
post.failed{
"id": "evt_9Zq1Lm4Xc7Vb2Nk8Ds3Pt6Wr",
"type": "post.failed",
"created_at": "2026-10-21T09:00:40Z",
"data": {
"publication_unique": "Pp5Kd8Lw2Qz9",
"mode": "postmode",
"project": { "project_unique": "Pj7Xc2Lq9W", "project_name": "My Project" },
"content": { "variation_unique": "Pv4Lk9Qz2Wx6", "title": "My X concept" },
"platform": "x",
"account": null,
"status": "failed",
"post_url": null,
"error_code": "token_expired",
"is_automation": true,
"posted_at": null,
"created_at": "2026-10-20T21:00:12Z",
"schedule_unique": "Sc6Hj1Kl8Mn3"
}
}
post.confirm_required{
"id": "evt_3Kd8Lq2Wz7Xc1Vb9Nm4Pt6Rs",
"type": "post.confirm_required",
"created_at": "2026-10-19T08:15:02Z",
"data": {
"schedule_unique": "aB3dE5fG7hJ9",
"state": "upcoming",
"confirm_state": "awaiting",
"confirm_mode": "required",
"can_approve": true,
"can_post_now": false,
"can_reschedule": false,
"scheduled_at": "2026-10-19T13:00:00Z",
"scheduled_at_local": "2026-10-19T22:00:00",
"timezone": "Asia/Tokyo",
"deadline_at": "2026-10-19T13:00:00Z",
"project": { "project_unique": "Pq7Rs9Tu1V", "project_name": "My brand" },
"concept": { "concept_unique": "Cx2Yz4Wv6Ut8", "concept_name": "Morning tips" },
"variation_unique": "Vr5Ws7Xt9Yu1",
"post_unique": "Ps3Qt5Ru7Sv9",
"post_type": "knowhow",
"text": "First post of the thread",
"thread": [
{ "text": "Second post", "image": null }
],
"image": { "url": "https://...", "expires_at": "2026-10-19T09:15:02Z" }
}
}
webhook.test{
"id": "evt_Q2x8Lr0aZk9MvB3pT7cYw1Nd",
"type": "webhook.test",
"created_at": "2026-10-21T12:00:00Z",
"data": { "webhook_unique": "Xk3pQ9vLm2Ta" }
}
Request headers
| Header | Description |
|---|---|
Content-Type: application/json | The body format |
Stepeee-Signature: t=1792584000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd | The signature. t is the time it was sent (UNIX seconds) and v1 is the signature (HMAC-SHA256 in hexadecimal). See signature verification below for how to verify it |
Stepeee-Event-Id: evt_Q2x8Lr0aZk9MvB3pT7cYw1Nd | The event ID (the same as id in the body). It does not change on resends, so use it to remove duplicates |
Stepeee-Event-Type: automation.failed | The event name (the same as type in the body) |
User-Agent: Stepeee-Webhooks/1.0 | The sender |
Signature verification
To confirm that the body you received was sent by Stepeee and has not been altered, verify the signature as follows.
- Split the Stepeee-Signature header on "," and take the value of t= (UNIX seconds) and the value of v1= (64 hexadecimal characters).
- Using the received body as raw bytes (do not parse and rebuild the JSON), build the string of the t value, ".", and the body joined together.
- Compute HMAC-SHA256 with the secret you received at registration (the whole value including whsec_) as the key, and encode it as lowercase hexadecimal.
- Compare the computed value with v1 using a comparison whose time does not depend on the content (such as hmac.compare_digest in Python or hash_equals in PHP). If they do not match, return 400 or similar and do not process it.
- To prevent replays of old requests, we recommend rejecting requests whose t differs from the current time by 5 minutes or more.
- Use id in the body to skip events you have already processed.
Verification code examples
import hmac, hashlib, time
def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask: verify(request.get_data(), request.headers["Stepeee-Signature"], SECRET)
const crypto = require("crypto");
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = parts.t, v1 = parts.v1;
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// Express: receive the body as raw bytes with express.raw()
app.post("/hooks/stepeee", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("Stepeee-Signature"), process.env.STEPEEE_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
});
function verifyStepeeeSignature(string $body, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
$parts[$k] = $v;
}
if (! isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'].'.'.$body, $secret);
return hash_equals($expected, $parts['v1']);
}
$ok = verifyStepeeeSignature(file_get_contents('php://input'), $_SERVER['HTTP_STEPEEE_SIGNATURE'] ?? '', $secret);
# BODY: raw request body, T: t from Stepeee-Signature, SECRET: whsec_...
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}'
# The signature is valid if the output equals v1
Success conditions and retries
If you return 2xx within 5 seconds, the event is treated as delivered. The response body is not read and redirects are not followed. Responses other than 2xx, timeouts, and connection failures are retried, with up to 8 sends including the first. The intervals before the next send are 1 minute → 5 minutes → 30 minutes → 2 hours → 4 hours → 6 hours → 10 hours, and if all 8 fail, the event is not sent. If the owner is below the Pro plan at the time of sending (for team members, if the team owner is not on the Team plan), the event is not sent. It is also not sent if the owner can no longer access the project at the time of sending (left the team, had permission removed, or the project was deleted).
Because the same event is resent until a 2xx is received, the same event may arrive two or more times. Remove duplicates with Stepeee-Event-Id (id in the body). The order of delivery is not guaranteed. Do heavy processing after returning 2xx.
Destination requirements
Destinations must be https URLs, and the port is limited to 443. URLs containing a user name or password cannot be registered either. URLs that resolve to internal or reserved addresses (private IPs, loopback, link-local, localhost, and so on) and URLs whose name cannot be resolved cannot be registered. The name is also resolved and checked just before sending, and if it resolves to an internal address, the send fails as url_blocked without being sent.