Articole PSK API v1 · public documentation back to the platform

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

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

ParameterTypeDescription
pageoptionalPage (min 1, default 1)
per_pageoptionalResults per page (1 to 200, default 50)
da_min, da_maxoptionalDomain Authority filter (0 to 100)
categoryoptionalCategory ID (integer)
searchoptionalSearch by the publication domain
price_maxoptionalMaximum EUR price on the cheapest offer of the publication
updated_sinceoptionalOnly 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:

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:

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)

ParameterTypeDescription
product_idrequiredThe offer ID (the offers[].id field). Accepted alias: offer_id
article_sourcerequired*One of: docx_upload, drive_link, redactare_750, redactare_1000. *NOT sent for Link insertion / Brand mention offers
insertion_urlconditionalOnly 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_sourceconditionalThe 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_idoptionalThe project the order belongs to (must be yours)
drive_urlconditionalRequired 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_fileoptional.doc/.docx file, max 10 MB (multipart only)
imagesconditionalArray 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, dofollowoptionalBoolean, 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_keywordoptionalPromoted keyword (max 200)
promoted_urloptionalPromoted URL. Accepted alias: target_url
keywordsoptionalArray (max 10) of { "anchor": "...", "url": "..." }
notesoptionalInstructions for the editor (max 2000). Becomes the first message on the order
external_referenceoptionalYour internal reference (max 128). Also used for idempotency
webhook_urloptionalWebhook 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_keyoptionalUnique 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).

HTTPCodeWhat it means and what to do
422DRIVE_LINK_INVALIDA file ID cannot be extracted from the link. Send one of the accepted forms at drive_url.
422DRIVE_FILE_NOT_ACCESSIBLEThe file does not exist or is not shared publicly. Set sharing to "Anyone with the link".
422DRIVE_FILE_UNSUPPORTEDThe file is not a Word document: we accept .doc, .docx or a Google Docs document.
422DRIVE_FILE_TOO_LARGEThe document exceeds 25 MB (or Google's 10 MB limit when exporting a Google Docs document). Reduce the images in it.
422COVER_IMAGE_REQUIREDThe 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.
503DRIVE_UNAVAILABLEA 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

ParameterTypeDescription
pageoptionalPage (min 1, default 1)
per_pageoptional1 to 100, default 25
statusoptionalOne 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_idoptionalFilter by project
updated_sinceoptionalOnly 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

FieldTypeDescription
files[]requiredOne 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

ParameterTypeDescription
messagerequiredText, 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

ParameterTypeDescription
pageoptionalPage (min 1, default 1)
per_pageoptional1 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

ParameterTypeDescription
amount_net_eurrequiredNet 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

ParameterTypeDescription
pageoptionalPage (min 1, default 1)
per_pageoptional1 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

ParameterTypeDescription
namerequiredThe project name (max 64)
urlrequiredThe 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

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

ParameterTypeDescription
pageoptionalPage (min 1, default 1)
per_pageoptional1 to 100, default 25
statusoptionalOne 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