Articole PSK API v1 · documentație publică înapoi la platforma

API v1 Articole PSK

API-ul îți permite să plasezi comenzi de articole pe publicații partenere, să urmărești statusul, să încarci conținut, să gestionezi wallet-ul și proiectele, și să fii notificat automat prin webhook când articolul este publicat.

Base URL: https://articole.psk.ro/api/v1

Toate răspunsurile sunt JSON. Toate sumele monetare sunt în EUR net (fără TVA), unde nu se specifică altfel. Datele de tip moment în timp apar atât ca timestamp Unix (întreg) cât și ca string ISO 8601 UTC.

HEADER Autentificare

Toate request-urile către API trebuie să conțină header-ul:

Authorization: Bearer psk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Cheia se generează din contul tău, secțiunea API. La generare se afișează o singură dată, salveaz-o într-un loc sigur. Dacă o pierzi, poți regenera cheia (cea veche devine invalida instant). Cheia începe mereu cu prefixul psk_.

Chei suplimentare. Din aceeași secțiune poți crea mai multe chei pe cont, fiecare cu un nume (de exemplu câte una pentru fiecare integrare). Au aceleași drepturi ca cheia principală și folosesc webhook-ul contului. Revocarea unei chei suplimentare o oprește doar pe ea: cheia principală și celelalte chei rămân valabile.

Erori

Erorile au format consistent:

{
  "error": "Mesaj uman-friendly",
  "code": "MACHINE_READABLE_CODE"
}

Coduri HTTP

Unele erori au și un câmp details cu informații structurate (ex. required_eur la sold insuficient, drive_url la erorile de Google Drive).

Limba mesajelor

Textele din câmpurile error, message și note sunt implicit în română. Trimite antetul Accept-Language: en ca să le primești în engleză (și numele tipurilor de articol și ale categoriilor); fără antet răspunsurile sunt în română. Valorile din code sunt aceleași în ambele limbi, deci integrarea se bazează pe ele. Exemplele din această pagină arată mesajele în română.

Rate limiting

60 request-uri pe minut per cont: toate cheile aceluiași cont împart limita. Când depășești limita primești HTTP 429 cu header Retry-After (secunde de așteptare). Fiecare răspuns include header-ul X-Request-Id pentru tracing, îl poți trimite și tu în request ca să corelezi log-urile.

GET /me

Verificare rapidă a cheii (sanity check). Returnează datele de bază ale contului autentificat. Util ca prim apel când integrezi.

Răspuns 200

{
  "id": 123,
  "email": "client@exemplu.ro",
  "name": "Nume client",
  "company": "Firma SRL",
  "class": "extern",
  "payment_method": "card",
  "wallet_balance_eur": 240.50,
  "webhook_url": "https://site-ul-tau.ro/webhook",
  "webhook_configured": true,
  "api_key_created_at": 1716200000,
  "api_key_last_used_at": 1716800000
}

GET /products

Lista publicațiilor partenere, grupate: o publicație este un obiect cu un array offers[] (fiecare ofertă este un tip de articol pe acea publicație). Prețurile sunt afișate pe tier-ul contului tău. Nu expunem costul de achiziție, email-ul publicației sau notele interne.

price este prețul pe care îl plătești la POST /orders. Când oferta are o reducere activa pentru tier-ul tău, price este prețul redus, iar regular_price, discount_percent și discount_ends_at (UTC) arată prețul fără reducere, procentul și până când e valabilă. Fără reducere: discount_percent 0 și discount_ends_at null.

Parametri query

ParametruTipDescriere
pageopționalPagina (min 1, implicit 1)
per_pageopționalRezultate pe pagina (1 până la 200, implicit 50)
da_min, da_maxopționalFiltru Domain Authority (0 până la 100)
categoryopționalID categorie (întreg)
searchopționalCăutare după domeniul publicației
price_maxopționalPreț maxim EUR pe cea mai ieftină oferta a publicației
updated_sinceopționalDoar publicațiile actualizate după această dată

Răspuns 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": "Știri",
      "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}

Detaliile unei singure publicații cu toate ofertele ei. {id} este ID-ul publicației (câmpul id din lista de mai sus). Răspunde 404 dacă publicația nu există sau nu are oferte active. Structura obiectului este identică cu un element din data[] la GET /products.

Tipuri de articol

Câmpul offers[].article_type conține numele tipului, exact cum apare în platforma. Tipurile curente:

Lista completă și actuală o ai mereu în răspunsul de la GET /products: numele vine direct din platforma, nu dintr-o listă statică.

POST /orders

Plasează o comandă de articol pe o ofertă. Suportă 4 surse pentru articol:

Oferte de tip Inserare link / Brand mention: nu există articol de livrat, deci article_source NU se trimite. În schimb trimiți insertion_url (URL-ul articolului EXISTENT al publicației în care se face inserarea) și, pentru inserare link, keywords cu ancora și linkul tău. Reguli de validare, aplicate la plasare: articolul țintă trebuie să fie pe domeniul publicației comandate; articolele care sunt ele însele advertoriale sau publicări plătite ale altor clienți sunt refuzate (422, INSERTION_TARGET_BLOCKED); dacă o ancoră nu apare în textul articolului țintă, comanda se creează și primești insertion_warnings în răspuns: publicația va adăuga o frază nouă care conține ancora.

Parametri body (JSON sau multipart/form-data)

ParametruTipDescriere
product_idobligatoriuID-ul ofertei (câmpul offers[].id). Alias acceptat: offer_id
article_sourceobligatoriu*Una din: docx_upload, drive_link, redactare_750, redactare_1000. *NU se trimite la ofertele Inserare link / Brand mention
insertion_urlcondiționatDoar la ofertele Inserare link / Brand mention: URL-ul articolului existent al publicației în care se face inserarea. Alias acceptat: existing_article_url
cover_sourcecondiționatURL-ul sursei imaginii de cover. Obligatoriu când publicația cere imagini (max_images > 0); altfel primești 422 COVER_SOURCE_REQUIRED. Alias acceptat: image_source
project_idopționalProiectul căruia îi aparține comanda (trebuie să fie al tău)
drive_urlcondiționatObligatoriu dacă article_source = drive_link: linkul documentului, partajat pe "Oricine are linkul" (Anyone with the link). Forme acceptate: https://docs.google.com/document/d/ID/..., https://drive.google.com/file/d/ID/..., https://drive.google.com/open?id=ID. Documentul (.doc, .docx sau Google Docs, max 25 MB) se descarcă la plasare; la .docx și Google Docs se preiau și imaginile din el. Dacă documentul nu conține nicio imagine și nu trimiți images[], comanda nu se creează (422 COVER_IMAGE_REQUIRED). Alias acceptat: drive_link
article_fileopționalFișier .doc/.docx, max 10 MB (doar pe multipart)
imagescondiționatArray de imagini, max 10, jpg/jpeg/png/webp/gif, max 5 MB fiecare (doar pe multipart). Orice articol trebuie să aibă o imagine de copertă: dacă documentul (din Drive sau article_file) nu conține nicio imagine, sau comanda e de redactare, trimite cel puțin o imagine aici; altfel primești 422 COVER_IMAGE_REQUIRED și comanda nu se creează. Prima imagine din images[] este coperta și trebuie să respecte formatul publicației: minim 1000 px lățime și raportul lățime/înălțime din picture_format (vezi GET /products; implicit 16:9), cu o abatere de cel mult 30%; altfel primești 422 COVER_FORMAT_INVALID. Când coperta vine din document, cel puțin o imagine din document trebuie să respecte același format
homepage, brand, dofollowopționalBoolean, opțiuni pe articol (dacă oferta le acceptă). Addon-urile cu preț ale publicațiilor nu sunt disponibile prin API; se comandă din platformă
promoted_keywordopționalCuvânt cheie promovat (max 200)
promoted_urlopționalURL promovat. Alias acceptat: target_url
keywordsopționalArray (max 10) de { "anchor": "...", "url": "..." }
notesopționalInstrucțiuni pentru redactor (max 2000). Devine primul mesaj pe comandă
external_referenceopționalReferință ta internă (max 128). Folosită și pentru idempotență
webhook_urlopționalURL webhook specific acestei comenzi (altfel se folosește cel din cont). Cere un webhook configurat în cont, care îți dă secretul de semnătura: fără el primești 422 WEBHOOK_SECRET_MISSING și comanda nu se creează
idempotency_keyopționalCheie unică (max 64). Același key în 24h returnează comanda existentă

Idempotență

Dacă trimiți din nou un idempotency_key folosit în ultimele 24 de ore, sau un external_reference deja existent, nu se creează o comandă nouă: primești comanda existentă cu "idempotent_replay": true.

Fără idempotency_key și fără external_reference, o cerere identică cu una trimisă în ultimele 90 de secunde (același corp: oferta, proiect, sursa, keywords, notes, coperta, opțiuni și aceleași fișiere, după nume și dimensiune) e luată drept dublu-click sau retry și primește tot "idempotent_replay": true cu prima comandă. Două comenzi diferite pe aceeași ofertă se creează separat; pentru comenzi repetate identice trimite chei diferite.

Răspuns 201

{
  "order": { "id": 9001, "status": "processing", "price": 75.00, "currency": "EUR", ... },
  "next_steps": {
    "type": "upload_required",
    "message": "Încarcă articolul (.docx) și imaginile prin POST /api/v1/orders/9001/upload ..."
  }
}

Câmpul next_steps.type poate fi: upload_required (sursa docx_upload, articolul se încarcă prin POST /orders/{id}/upload), in_progress (redactare de către echipa noastră, inserare, sau sursa drive_link cu documentul deja descărcat și atașat comenzii: nu mai trebuie niciun upload) sau drive_pending (sursa drive_link, dar documentul NU este atașat comenzii, la comenzile mai vechi sau dacă salvarea lui a eșuat; îl încarci tu prin POST /orders/{id}/upload, nu îl descărcăm automat; după upload-ul documentului, comanda nu îl mai așteaptă și next_steps.type devine in_progress).

Erori la drive_link

Cu article_source = drive_link, documentul se descarcă din Google Drive în momentul plasării comenzii. Dacă nu poate fi citit, comanda nu se creează și nu se debitează nimic: primești una din erorile de mai jos, corectezi și retrimiți aceeași cerere. În details primești drive_url și, la erorile 422, drive_error (textul erorii, de exemplu răspunsul primit de la Google Drive).

HTTPCodCe înseamnă și ce faci
422DRIVE_LINK_INVALIDDin link nu se poate scoate ID-ul unui fișier. Trimite unul din formele acceptate la drive_url.
422DRIVE_FILE_NOT_ACCESSIBLEFișierul nu există sau nu e partajat public. Setează partajarea pe "Oricine are linkul" (Anyone with the link).
422DRIVE_FILE_UNSUPPORTEDFișierul nu e un document Word: acceptăm .doc, .docx sau un document Google Docs.
422DRIVE_FILE_TOO_LARGEDocumentul depășește 25 MB (sau limita Google de 10 MB la exportul unui document Google Docs). Micșorează imaginile din el.
422COVER_IMAGE_REQUIREDDocumentul nu conține nicio imagine și nu ai trimis images[]. Orice articol are nevoie de o imagine de copertă: pune una în document sau trimite images[] în aceeași cerere. Comanda nu a fost creată.
422UNPAID_INVOICESContul are două sau mai multe facturi lunare neplătite, cel puțin una cu scadența depășită. Comenzile noi sunt blocate cât timp rămân cel puțin două neplătite (plata se face din cont, la Facturi, cu cardul, sau prin transfer bancar). Mesajul listează facturile.
422COVER_FORMAT_INVALIDImaginea de copertă (prima din images[] sau, când vine din document, oricare imagine din el) nu respectă formatul publicației: minim 1000 px lățime și raportul din picture_format (implicit 16:9), toleranță 30%. Mesajul spune dimensiunile primite și înălțimea corectă. Comanda nu a fost creată.
503DRIVE_UNAVAILABLEProblema temporară la noi sau la Google. Reîncearcă în câteva minute sau trimite articolul ca fișier, cu article_source = docx_upload.
{
  "error": "Documentul din Google Drive nu poate fi citit: nu există sau nu este partajat public. ... Nu s-a creat nicio comandă.",
  "code": "DRIVE_FILE_NOT_ACCESSIBLE",
  "details": {
    "drive_url": "https://docs.google.com/document/d/.../edit",
    "drive_error": "Drive API: File not found: ..."
  }
}

GET /orders

Lista comenzilor tale, paginată, cele mai noi primele.

Parametri query

ParametruTipDescriere
pageopționalPagina (min 1, implicit 1)
per_pageopțional1 până la 100, implicit 25
statusopționalUna din: pending, processing, published, failed, rejected. failed înseamnă comandă anulată sau refuzată de publicație (status_internal canceled sau publisher_refused); după un refuz, dacă echipa mută comanda la altă publicație, statusul revine la processing
project_idopționalFiltru pe proiect
updated_sinceopționalDoar comenzile cu data plasării, a publicării sau a finalizării după această dată. Anularea, refuzul publicației și celelalte schimbări de status nu schimbă aceste date: le primești prin webhook order.status_changed (sau recitești comenzile deschise fără filtru)

Răspuns 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}

Detaliile complete ale unei comenzi. Pe lângă câmpurile din lista, răspunsul include și cover_source, image_source, article_type, keywords, target_url, insertion_url, insertion_warnings, notes și webhook_url. Răspunde 404 dacă comanda nu există sau nu îți aparține.

POST /orders/{id}/upload

Încarcă fișierele unei comenzi (articol .docx și/sau imagini). Folosit mai ales pentru comenzile cu sursa docx_upload. Când articolul .docx ajunge, comanda intră în procesare: echipa verifică articolul și îl trimite la publicație (status_internal processing; statusul public rămâne processing). Documentul se primește doar împreună cu o imagine de copertă (în aceeași cerere, în document sau deja existentă pe comandă); altfel primești 422 COVER_IMAGE_REQUIRED și niciun fișier din cerere nu se salvează. La o comandă care nu are încă nicio imagine, prima imagine din cerere (sau o imagine din document) devine coperta și trebuie să respecte formatul publicației (picture_format, minim 1000 px lățime, toleranță 30%); altfel primești 422 COVER_FORMAT_INVALID și nimic nu se salvează.

Request: multipart/form-data

CâmpTipDescriere
files[]obligatoriuUnul sau mai multe fișiere. Acceptate: .doc, .docx, .jpg, .jpeg, .png, .webp, .gif. Max 10 MB fiecare, max 5 fișiere per request

Răspuns 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

Mesajele de pe comandă (conversația cu echipa). Mesajele interne nu sunt expuse, la fel ca în interfața clientului.

Răspuns 200

{
  "data": [
    { "from": "Nume client", "message": "Vă rog să folosiți tonul formal.", "date": 1716200100 }
  ]
}

POST /orders/{id}/messages

Adaugă un mesaj pe comandă. Echipa noastră este notificată pe email.

Parametri body

ParametruTipDescriere
messageobligatoriuText, între 1 și 5000 de caractere

Răspuns 201

{ "status": "sent", "date": 1716200200 }

GET /wallet

Soldul portofelului. TVA-ul se aplică în funcție de profilul tău fiscal.

Răspuns 200

{
  "balance_net_eur": 240.50,
  "balance_gross_eur": 291.01,
  "currency": "EUR",
  "apply_vat": true
}

GET /wallet/transactions

Istoricul tranzacțiilor din wallet, paginat.

Parametri query

ParametruTipDescriere
pageopționalPagina (min 1, implicit 1)
per_pageopțional1 până la 100, implicit 25

Răspuns 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

Inițiază o alimentare de wallet prin card. Răspunsul conține datele de plată: faci un HTTP POST către action_url cu câmpurile din transfer_data (form-urlencoded). După plata, banca confirmă prin IPN și wallet-ul se creditează automat.

Parametri body

ParametruTipDescriere
amount_net_eurobligatoriuSuma netă în EUR, între 20 și 5000

Răspuns 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 către action_url cu câmpurile din transfer_data (form-urlencoded)."
  }
}

Răspuns 422

Pentru conturile din România fără județ și localitate în Contul meu: alimentarea se facturează automat, iar factura are nevoie de ele. Completează-le o singură dată, apoi reia cererea.

{
  "error": "Contul nu are județul și localitatea de care are nevoie factura. ...",
  "code": "BILLING_ADDRESS_INCOMPLETE"
}

GET /projects

Lista proiectelor tale, paginată.

Parametri query

ParametruTipDescriere
pageopționalPagina (min 1, implicit 1)
per_pageopțional1 până la 100, implicit 50

Răspuns 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

Creează un proiect nou. Proiectele grupează comenzile (le poți referi prin project_id la POST /orders).

Parametri body

ParametruTipDescriere
nameobligatoriuNumele proiectului (max 64)
urlobligatoriuDomeniul promovat, fără protocol (ex. exemplu.ro)

Răspuns 201

{ "id": 5, "name": "Campanie vara", "url": "exemplu.ro", "description": null }

Datele de facturare per proiect (billing override) se configurează din interfața platformei, nu prin API.

GET /projects/{id}

Detaliile unui proiect. Răspunde 404 dacă proiectul nu există sau nu îți aparține.

Webhook-uri

Când statusul unei comenzi se schimbă, îți trimitem un POST către URL-ul tău, ca să nu fie nevoie de polling. Configurezi webhook_url și webhook_secret în contul tău (secțiunea API), sau trimiți un webhook_url per comandă la POST /orders.

Evenimente

Livrare și retry

Webhook-urile sunt trimise de un proces care rulează în fiecare minut. Pe răspuns 5xx, 408, 429 sau timeout (10 secunde) reîncercăm cu backoff: după 1 minut, 5 minute, apoi 30 de minute. După 3 încercări eșuate, livrarea e marcată failed. Un răspuns 3xx sau 4xx (altul decât 408 și 429) marchează livrarea failed din prima: redirecturile nu se urmează, iar un 404 nu se schimbă la reîncercare. Fiecare request poartă header-ele X-PSK-Signature, X-PSK-Event și X-PSK-Delivery-Id.

Exemplu 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

Istoricul livrărilor de webhook, util pentru debugging (vezi de ce a eșuat un webhook anume).

Parametri query

ParametruTipDescriere
pageopționalPagina (min 1, implicit 1)
per_pageopțional1 până la 100, implicit 25
statusopționalUna din: pending, success, failed

Răspuns 200

{
  "data": [
    {
      "id": 41, "event": "order.status_changed", "order_id": 9001,
      "webhook_url": "https://site-ul-tau.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 }
}

Verificare semnătura webhook

Fiecare cerere webhook trimisă de noi include header-ul X-PSK-Signature cu valoarea sha256=<hex>, calculată ca HMAC-SHA256 al body-ului folosind webhook_secret din contul tău.

Exemplu PHP:

$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;
}
// ... payload-ul este de încredere