Articole PSK API v1
The API lets you place article orders on partner publications, track their status, upload content, manage your wallet and projects, and get notified automatically through a webhook when the article is published.
Base URL: https://articole.psk.ro/api/v1
All responses are JSON. All monetary amounts are in net EUR (excluding VAT) unless stated otherwise. Points in time appear both as a Unix timestamp (integer) and as an ISO 8601 UTC string.
HEADER Authentication
Every request to the API must include the header:
Authorization: Bearer psk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The key is generated from your account, in the API section. It is shown
only once when generated, so save it in a safe place. If you lose it, you can regenerate
the key (the old one becomes invalid instantly). The key always starts with the
psk_ prefix.
Additional keys. From the same section you can create several keys per account, each with a name (for example one for each integration). They have the same rights as the main key and use the account webhook. Revoking an additional key stops only that key: the main key and the other keys remain valid.
Errors
Errors have a consistent format:
{
"error": "Human-friendly message",
"code": "MACHINE_READABLE_CODE"
}
HTTP codes
400malformed request (e.g. no file on upload)401the API key is missing, has a wrong format or has been revoked402insufficient wallet balance403you do not have permission for this resource404the resource does not exist422validation failed (e.g. missing parameter, brand not allowed on the website)429too many requests (rate limit)500server error (contact us at articole@psk.ro)503an external service the request depends on is not responding right now (e.g. Google Drive fordrive_link); retry in a few minutes
Some errors also have a details field with structured information (e.g. required_eur
for insufficient balance, drive_url for Google Drive errors).
Message language
The texts in the error, message and note fields are in Romanian by default.
Send the Accept-Language: en header to get them in English (article type and category names
included); without the header, responses are in Romanian. The code values are the same in both languages, so
integrations should rely on them. The examples on this page show the Romanian messages.
Rate limiting
60 requests per minute per account: all the keys of the same account share the limit. When you exceed the limit you receive
HTTP 429 with a Retry-After header (seconds to wait).
Every response includes the X-Request-Id header for tracing;
you can also send it in the request to correlate logs.
GET /me
Quick key check (sanity check). Returns the basic data of the authenticated account. Useful as the first call when integrating.
Response 200
{
"id": 123,
"email": "client@exemplu.ro",
"name": "Client name",
"company": "Company SRL",
"class": "extern",
"payment_method": "card",
"wallet_balance_eur": 240.50,
"webhook_url": "https://your-site.ro/webhook",
"webhook_configured": true,
"api_key_created_at": 1716200000,
"api_key_last_used_at": 1716800000
}
GET /products
The list of partner publications, grouped: a publication is an object with an
offers[] array (each offer is an article type on that publication).
Prices are shown for your account tier. We do not expose the purchase cost,
the publication email or internal notes.
price is the price you pay at POST /orders. When the offer
has an active discount for your tier, price is the discounted price, and
regular_price, discount_percent and discount_ends_at
(UTC) show the price without discount, the percentage and until when it is valid. Without a discount:
discount_percent 0 and discount_ends_at null.
Query parameters
| Parameter | Type | Description |
|---|---|---|
page | optional | Page (min 1, default 1) |
per_page | optional | Results per page (1 to 200, default 50) |
da_min, da_max | optional | Domain Authority filter (0 to 100) |
category | optional | Category ID (integer) |
search | optional | Search by the publication domain |
price_max | optional | Maximum EUR price on the cheapest offer of the publication |
updated_since | optional | Only publications updated after this date |
Response 200
{
"data": [
{
"id": 88,
"name": "exemplu.ro",
"website": "exemplu.ro",
"da": 42, "dr": 38, "spam_score": 2,
"referring_domains": 1200, "traffic": 85000,
"category_id": 5, "category_name": "Stiri",
"is_active": true,
"picture_format": "16:9",
"diacritics_required": true,
"updated_at": "2026-05-20T08:00:00Z",
"offers": [
{
"id": 311,
"article_type": "Articol cu brand",
"article_type_id": 57,
"price": 60.00, "currency": "EUR",
"regular_price": 75.00, "discount_percent": 20,
"discount_ends_at": "2026-10-30T22:00:00Z",
"accepts_brand": true, "accepts_homepage": false,
"dofollow_links": 2, "nofollow_links": 0,
"max_images": 3, "turnaround_days": null,
"requirements": "...", "requirements_en": "...",
"sponsorized_label": "Articol sponsorizat",
"is_active": true
}
]
}
],
"meta": { "current_page": 1, "last_page": 12, "per_page": 50, "total": 580 }
}
GET /products/{id}
The details of a single publication with all its offers. {id} is the
publication ID (the id field in the list above). Responds
404 if the publication does not exist or has no active offers. The object
structure is identical to an element of data[] at GET /products.
Article types
The offers[].article_type field contains the name of the type, exactly as it
appears in the platform (in Romanian). The current types:
Articol fara brand(unbranded article) editorial content with a link, without the brand name (natural link profile)Articol cu brand(branded article) your name and links in the articleArticol cu promovare(article with promotion) publication plus visibility on the publication homepageInserare link(link insertion) inserting a link into an EXISTING article of the publication (seeinsertion_urlat POST /orders)Brand mentionmentioning the brand in an existing article of the publication- Special niches, accepted only by the publications listed with them:
Articol Bet/Casino,Crypto/Forex/IFN/Banci,Articol Videochat,Articol Sex Shop,Bauturi alcoolice(alcoholic beverages)
The complete and current list is always in the response of
GET /products: the name comes directly from the platform, not from a static list.
POST /orders
Places an article order on an offer. Supports 4 sources for the article:
docx_uploadyou upload a .docx file (now or later, through the upload endpoint)drive_linka public Google Drive link to the article document (.doc, .docx or Google Docs). The document is downloaded when the order is placed; if it cannot be read, the order is not created (see drive_link errors)redactare_750an article written by us (750 words)redactare_1000an article written by us (1000 words)
Link insertion / Brand mention offers: there is no article to
deliver, so article_source is NOT sent. Instead you send
insertion_url (the URL of the publication's EXISTING article in which the
insertion is made) and, for link insertion, keywords with your anchor and link.
Validation rules, applied when placing the order: the target article must be on the domain
of the ordered publication; articles that are themselves advertorials or paid publications
of other clients are refused (422,
INSERTION_TARGET_BLOCKED); if an anchor does not appear in the text of the target
article, the order is created and you receive insertion_warnings in the response:
the publication will add a new sentence containing the anchor.
Body parameters (JSON or multipart/form-data)
| Parameter | Type | Description |
|---|---|---|
product_id | required | The offer ID (the offers[].id field). Accepted alias: offer_id |
article_source | required* | One of: docx_upload, drive_link, redactare_750, redactare_1000. *NOT sent for Link insertion / Brand mention offers |
insertion_url | conditional | Only for Link insertion / Brand mention offers: the URL of the publication's existing article in which the insertion is made. Accepted alias: existing_article_url |
cover_source | conditional | The URL of the cover image source. Required when the publication requires images (max_images > 0); otherwise you receive 422 COVER_SOURCE_REQUIRED. Accepted alias: image_source |
project_id | optional | The project the order belongs to (must be yours) |
drive_url | conditional | Required if article_source = drive_link: the document link, shared as "Anyone with the link". Accepted forms: https://docs.google.com/document/d/ID/..., https://drive.google.com/file/d/ID/..., https://drive.google.com/open?id=ID. The document (.doc, .docx or Google Docs, max 25 MB) is downloaded when the order is placed; for .docx and Google Docs the images in it are taken as well. If the document contains no image and you do not send images[], the order is not created (422 COVER_IMAGE_REQUIRED). Accepted alias: drive_link |
article_file | optional | .doc/.docx file, max 10 MB (multipart only) |
images | conditional | Array of images, max 10, jpg/jpeg/png/webp/gif, max 5 MB each (multipart only). Every article must have a cover image: if the document (from Drive or article_file) contains no image, or the order is for writing, send at least one image here; otherwise you receive 422 COVER_IMAGE_REQUIRED and the order is not created |
homepage, brand, dofollow | optional | Boolean, options on the article (if the offer accepts them). Paid add-ons of the publications are not available through the API; they are ordered from the platform |
promoted_keyword | optional | Promoted keyword (max 200) |
promoted_url | optional | Promoted URL. Accepted alias: target_url |
keywords | optional | Array (max 10) of { "anchor": "...", "url": "..." } |
notes | optional | Instructions for the editor (max 2000). Becomes the first message on the order |
external_reference | optional | Your internal reference (max 128). Also used for idempotency |
webhook_url | optional | Webhook URL specific to this order (otherwise the account one is used). Requires a webhook configured in the account, which gives you the signing secret: without it you receive 422 WEBHOOK_SECRET_MISSING and the order is not created |
idempotency_key | optional | Unique key (max 64). The same key within 24h returns the existing order |
Idempotency
If you send again an idempotency_key used in the last 24 hours,
or an external_reference that already exists, no new order is created:
you receive the existing order with "idempotent_replay": true.
Without idempotency_key and without external_reference, a request identical to one
sent in the last 90 seconds (same body: offer, project, source, keywords, notes, cover,
options and the same files, by name and size) is treated as a double click or a retry and
also receives "idempotent_replay": true with the first order. Two different orders on
the same offer are created separately; for identical repeated orders send different keys.
Response 201
{
"order": { "id": 9001, "status": "processing", "price": 75.00, "currency": "EUR", ... },
"next_steps": {
"type": "upload_required",
"message": "Incarca articolul (.docx) si imaginile prin POST /api/v1/orders/9001/upload ..."
}
}
The next_steps.type field can be: upload_required (source docx_upload,
the article is uploaded through POST /orders/{id}/upload), in_progress (writing
by our team, insertion, or source drive_link with the document already downloaded and attached to the
order: no upload needed) or drive_pending (source drive_link, but the
document is NOT attached to the order, for older orders or if saving it failed;
you upload it yourself through POST /orders/{id}/upload, we do not download it automatically; after the
document upload, the order no longer waits for it and next_steps.type becomes in_progress).
drive_link errors
With article_source = drive_link, the document is downloaded from Google Drive when the
order is placed. If it cannot be read, the order is not created and nothing is
charged: you receive one of the errors below, fix it and resend the same request. In
details you receive drive_url and, for 422 errors, drive_error
(the error text, for example the response received from Google Drive).
| HTTP | Code | What it means and what to do |
|---|---|---|
422 | DRIVE_LINK_INVALID | A file ID cannot be extracted from the link. Send one of the accepted forms at drive_url. |
422 | DRIVE_FILE_NOT_ACCESSIBLE | The file does not exist or is not shared publicly. Set sharing to "Anyone with the link". |
422 | DRIVE_FILE_UNSUPPORTED | The file is not a Word document: we accept .doc, .docx or a Google Docs document. |
422 | DRIVE_FILE_TOO_LARGE | The document exceeds 25 MB (or Google's 10 MB limit when exporting a Google Docs document). Reduce the images in it. |
422 | COVER_IMAGE_REQUIRED | The document contains no image and you did not send images[]. Every article needs a cover image: put one in the document or send images[] in the same request. The order was not created. |
503 | DRIVE_UNAVAILABLE | A temporary problem on our side or at Google. Retry in a few minutes or send the article as a file, with article_source = docx_upload. |
{
"error": "Documentul din Google Drive nu poate fi citit: nu exista sau nu este partajat public. ... Nu s-a creat nicio comanda.",
"code": "DRIVE_FILE_NOT_ACCESSIBLE",
"details": {
"drive_url": "https://docs.google.com/document/d/.../edit",
"drive_error": "Drive API: File not found: ..."
}
}
GET /orders
The list of your orders, paginated, newest first.
Query parameters
| Parameter | Type | Description |
|---|---|---|
page | optional | Page (min 1, default 1) |
per_page | optional | 1 to 100, default 25 |
status | optional | One of: pending, processing, published, failed, rejected. failed means the order was cancelled or refused by the publication (status_internal canceled or publisher_refused); after a refusal, if the team moves the order to another publication, the status returns to processing |
project_id | optional | Filter by project |
updated_since | optional | Only orders whose placement, publication or completion date is after this date. Cancellation, publisher refusal and the other status changes do not change these dates: you receive them through the order.status_changed webhook (or re-read the open orders without the filter) |
Response 200
{
"data": [
{
"id": 9001, "master_id": "...", "project_id": 4,
"offer_id": 311, "publication": "exemplu.ro", "website": "exemplu.ro",
"status": "published", "status_internal": "complete",
"price": 75.00, "currency": "EUR", "total_eur": 75.00,
"external_reference": "ref-123",
"order_date": 1716200000, "created_at": "2026-05-20T08:00:00Z",
"updated_at": "2026-05-22T10:00:00Z",
"published_url": "https://exemplu.ro/articol", "publish_url": "https://exemplu.ro/articol",
"published_at": "2026-05-22T10:00:00Z", "publish_date": 1716372000
}
],
"meta": { "page": 1, "per_page": 25, "total": 40, "last_page": 2 }
}
GET /orders/{id}
The full details of an order. Besides the fields in the list, the response
also includes cover_source, image_source, article_type,
keywords, target_url, insertion_url,
insertion_warnings, notes and webhook_url.
Responds 404 if the order does not exist or does not belong to you.
POST /orders/{id}/upload
Uploads the files of an order (.docx article and/or images). Used mainly
for orders with the docx_upload source. When the .docx article arrives,
the order enters processing: the team checks the article and sends it to the publication (status_internal processing; the public status stays processing). The document is accepted only together with a cover
image (in the same request, inside the document or already existing on the order); otherwise you receive
422 COVER_IMAGE_REQUIRED and no file from the request is saved.
Request: multipart/form-data
| Field | Type | Description |
|---|---|---|
files[] | required | One or more files. Accepted: .doc, .docx, .jpg, .jpeg, .png, .webp, .gif. Max 10 MB each, max 5 files per request |
Response 200
{
"uploaded": [
{ "id": 5001, "media_type": "document", "filename": "articol.docx", "url": "https://articole.psk.ro/storage/orders/9001/..." }
],
"order_status": "processing"
}
GET /orders/{id}/messages
The messages on the order (the conversation with the team). Internal messages are not exposed, the same as in the client interface.
Response 200
{
"data": [
{ "from": "Nume client", "message": "Va rog sa folositi tonul formal.", "date": 1716200100 }
]
}
POST /orders/{id}/messages
Adds a message on the order. Our team is notified by email.
Body parameters
| Parameter | Type | Description |
|---|---|---|
message | required | Text, between 1 and 5000 characters |
Response 201
{ "status": "sent", "date": 1716200200 }
GET /wallet
The wallet balance. VAT is applied according to your tax profile.
Response 200
{
"balance_net_eur": 240.50,
"balance_gross_eur": 291.01,
"currency": "EUR",
"apply_vat": true
}
GET /wallet/transactions
The wallet transaction history, paginated.
Query parameters
| Parameter | Type | Description |
|---|---|---|
page | optional | Page (min 1, default 1) |
per_page | optional | 1 to 100, default 25 |
Response 200
{
"data": [
{ "id": 12, "type": "credit", "amount_net_eur": 100.00, "description": "Alimentare card", "order_id": null, "created_at": 1716200000 }
],
"meta": { "page": 1, "per_page": 25, "total": 12, "last_page": 1 }
}
POST /wallet/topup
Initiates a wallet top-up by card. The response contains the payment
data: you make an HTTP POST to action_url with the fields from
transfer_data (form-urlencoded). After payment, the bank confirms through IPN
and the wallet is credited automatically.
Body parameters
| Parameter | Type | Description |
|---|---|---|
amount_net_eur | required | Net amount in EUR, between 20 and 5000 |
Response 200
{
"master_order_id": 7700,
"amount_net_eur": 100.00,
"amount_gross": 121.00,
"currency": "RON",
"payment": {
"action_url": "https://...netopia-payments...",
"transfer_data": { "...": "..." },
"method": "POST",
"note": "Trimite POST catre action_url cu campurile din transfer_data (form-urlencoded)."
}
}
Response 422
For Romanian accounts without county and locality in My account: the top-up is invoiced automatically, and the invoice needs them. Fill them in once, then retry the request.
{
"error": "The account is missing the county and city the invoice needs. ...",
"code": "BILLING_ADDRESS_INCOMPLETE"
}
GET /projects
The list of your projects, paginated.
Query parameters
| Parameter | Type | Description |
|---|---|---|
page | optional | Page (min 1, default 1) |
per_page | optional | 1 to 100, default 50 |
Response 200
{
"data": [
{ "id": 4, "name": "Campanie primavara", "url": "exemplu.ro", "description": null }
],
"meta": { "page": 1, "per_page": 50, "total": 4, "last_page": 1 }
}
POST /projects
Creates a new project. Projects group orders (you can reference them through
project_id at POST /orders).
Body parameters
| Parameter | Type | Description |
|---|---|---|
name | required | The project name (max 64) |
url | required | The promoted domain, without protocol (e.g. exemplu.ro) |
Response 201
{ "id": 5, "name": "Campanie vara", "url": "exemplu.ro", "description": null }
Per-project invoicing details (billing override) are configured from the platform interface, not through the API.
GET /projects/{id}
The details of a project. Responds 404 if the project does not exist or
does not belong to you.
Webhooks
When the status of an order changes, we send a POST to your URL,
so that no polling is needed. You configure webhook_url and
webhook_secret in your account (API section), or send a
webhook_url per order at POST /orders.
Events
order.createdthe order was createdorder.status_changedthe public status changed (includesold_statusandnew_status)order.changes_requestedchanges were requested on the order; includesmessage(the text with what needs to change). The public status staysprocessing. You reply throughPOST /orders/{id}/messagesand/or re-upload the article withPOST /orders/{id}/upload(fieldfiles[]).
Delivery and retry
Webhooks are sent by a process that runs every minute. On a
5xx, 408, 429 response or a timeout (10 seconds) we retry with backoff: after 1 minute, 5 minutes, then
30 minutes. After 3 failed attempts, the delivery is marked failed. A 3xx or 4xx response (other than 408 and 429) marks the delivery failed right away: redirects are not followed, and a 404 does not change on retry.
Every request carries the X-PSK-Signature, X-PSK-Event
and X-PSK-Delivery-Id headers.
Example payload
{
"event": "order.status_changed",
"order_id": 9001,
"master_id": "...",
"status": "published",
"status_internal": "complete",
"old_status": "processing",
"new_status": "published",
"project_id": 4,
"publication": "exemplu.ro",
"website": "exemplu.ro",
"price": 75.00, "currency": "EUR", "total_eur": 75.00,
"external_reference": "ref-123",
"published_url": "https://exemplu.ro/articol",
"publish_url": "https://exemplu.ro/articol",
"published_at": "2026-05-22T10:00:00Z",
"timestamp": "2026-05-22T10:00:01Z"
}
GET /webhooks/deliveries
The webhook delivery history, useful for debugging (see why a particular webhook failed).
Query parameters
| Parameter | Type | Description |
|---|---|---|
page | optional | Page (min 1, default 1) |
per_page | optional | 1 to 100, default 25 |
status | optional | One of: pending, success, failed |
Response 200
{
"data": [
{
"id": 41, "event": "order.status_changed", "order_id": 9001,
"webhook_url": "https://your-site.ro/webhook",
"status": "success", "http_status": 200, "attempts": 1,
"response_body": "ok",
"created_at": 1716372001, "delivered_at": 1716372002, "next_attempt_at": 1716372001,
"payload": { "...": "..." }
}
],
"meta": { "page": 1, "per_page": 25, "total": 41, "last_page": 2 }
}
Webhook signature verification
Every webhook request we send includes the X-PSK-Signature header
with the value sha256=<hex>, computed as the HMAC-SHA256 of the body
using the webhook_secret from your account.
PHP example:
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PSK_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $payload, $WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit;
}
// ... the payload can be trusted