Pentify
SDKs

Rust

No official Pentify crate yet. This page documents how to consume the Pentify API from Rust today: a hand-rolled reqwest client (recommended) or openapi-generator. Both target the same OpenAPI 3.1 spec at https://api.pentify.io/openapi.json.

Note
An official pentify Rust crate is on the roadmap. Until then this guide reflects how the Pentify-internal teams (Pentify Terminal in particular) consume the API. The shapes here match https://api.pentify.io/openapi.json exactly.

There's no official SDK yet

cargo add pentifydoesn't resolve. Two paths until it does:

  • Path A — hand-rolled reqwest client. Recommended. ~250 lines, typed errors, no codegen toolchain. This is what Pentify Terminal ships.
  • Path B — openapi-generator. Verbose, requires a JVM, errors are stringly-typed. Useful if you want every endpoint without writing wrappers.

Path A — Hand-rolled reqwest client

Add the dependencies, drop these four files into src/, and you have a typed async client with structured errors.

[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
thiserror = "1"

Wire mod client; mod error; mod types; in lib.rs and re-export what you need. The error enum maps the canonical error envelope 1:1.

Path B — Codegen from openapi.json

# Java required for openapi-generator-cli
brew install openapi-generator

openapi-generator generate \
  -i https://api.pentify.io/openapi.json \
  -g rust \
  -o ./pentify-generated \
  --additional-properties=packageName=pentify_api,supportAsync=true

Generated clients are verbose and you have to maintain a fork to customize errors or auth headers. oapi-codegen is Go-only — for Rust the OpenAPI Generator rust / rust-reqwest templates are the only mature options. For most users Path A is leaner.

Recipes

Login (device pairing)

Mirrors what Pentify Terminal does: open the browser pairing URL, poll /auth/terminal/poll until the user approves.

// Cargo.toml additions:
//   open = "5"
//   hostname = "0.4"
//   rand = "0.8"
//   hex = "0.4"
//   anyhow = "1"

use std::time::{Duration, Instant};
use tokio::time::sleep;
use serde::Deserialize;
use anyhow::{bail, Result};

#[derive(Deserialize)]
struct PollResponse {
    key: String,
}

pub async fn pentify_login() -> Result<String> {
    let device_id = hex::encode(rand::random::<[u8; 16]>());
    let device_name = hostname::get()?.to_string_lossy().into_owned();
    let url = format!(
        "https://app.pentify.io/auth/terminal-pair?device_id={device_id}&device_name={device_name}"
    );
    open::that(&url)?;

    let client = reqwest::Client::new();
    let deadline = Instant::now() + Duration::from_secs(300);
    while Instant::now() < deadline {
        let resp = client
            .get(format!(
                "https://api.pentify.io/auth/terminal/poll?device_id={device_id}"
            ))
            .send()
            .await?;
        match resp.status().as_u16() {
            200 => return Ok(resp.json::<PollResponse>().await?.key),
            404 => sleep(Duration::from_secs(2)).await,
            410 => bail!("pairing already consumed"),
            s => bail!("unexpected status {s}"),
        }
    }
    bail!("pairing timed out")
}

Run a scan with idempotency

Every POST that creates a billable resource accepts an Idempotency-Key header. Generate a UUID per logical attempt and reuse it on retries — the Worker dedupes for 24h.

use uuid::Uuid;

let key = Uuid::new_v4().to_string();
let scan = client
    .create_scan(
        &CreateScan {
            target: "example.com".into(),
            scan_type: "quick".into(),
        },
        Some(&key),
    )
    .await?;

// Retrying with the same key returns the original scan instead of
// creating a duplicate. On a stored-but-different-body collision the
// API returns 409 -> PentifyError::IdempotencyKeyConflict.

Poll until done

use tokio::time::{sleep, Duration};

async fn wait_until_done(
    client: &PentifyClient,
    scan_id: &str,
) -> Result<Scan, PentifyError> {
    loop {
        let scan = client.get_scan(scan_id).await?;
        match scan.status.as_str() {
            "completed" | "failed" | "canceled" => return Ok(scan),
            _ => sleep(Duration::from_secs(5)).await,
        }
    }
}

Verify HMAC webhook signature

The pentify-signature header is t=<unix>,v1=<hex>. Sign {t}.{raw_body} with HMAC-SHA256 using your endpoint secret, constant-time compare. Reject anything older than max_age_secs (5 minutes is the recommended window).

// Cargo.toml additions:
//   hmac = "0.12"
//   sha2 = "0.10"
//   subtle = "2"
//   thiserror = "1"

use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;

type HmacSha256 = Hmac<Sha256>;

#[derive(Debug, Error)]
pub enum VerifyError {
    #[error("malformed signature header")]
    Malformed,
    #[error("timestamp outside replay window")]
    StaleTimestamp,
    #[error("signature mismatch")]
    Mismatch,
    #[error("hmac init")]
    Hmac,
}

pub fn verify_pentify_signature(
    raw_body: &[u8],
    signature_header: &str,
    secret: &str,
    max_age_secs: u64,
) -> Result<(), VerifyError> {
    // Header format: t=<unix>,v1=<hex>
    let mut t: Option<i64> = None;
    let mut v1: Option<&str> = None;
    for part in signature_header.split(',') {
        let (k, v) = part.split_once('=').ok_or(VerifyError::Malformed)?;
        match k.trim() {
            "t" => t = v.trim().parse().ok(),
            "v1" => v1 = Some(v.trim()),
            _ => {}
        }
    }
    let t = t.ok_or(VerifyError::Malformed)?;
    let v1 = v1.ok_or(VerifyError::Malformed)?;

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|_| VerifyError::Malformed)?
        .as_secs() as i64;
    if (now - t).unsigned_abs() > max_age_secs {
        return Err(VerifyError::StaleTimestamp);
    }

    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
        .map_err(|_| VerifyError::Hmac)?;
    mac.update(t.to_string().as_bytes());
    mac.update(b".");
    mac.update(raw_body);
    let expected = mac.finalize().into_bytes();
    let provided = hex::decode(v1).map_err(|_| VerifyError::Malformed)?;

    if expected.ct_eq(&provided).into() {
        Ok(())
    } else {
        Err(VerifyError::Mismatch)
    }
}

Full scheme and replay rules in Webhooks.

Read findings + download evidence

let findings = client.list_findings(&scan.id).await?;
for f in findings.data {
    if f.severity == Severity::Critical {
        for url in &f.evidence_urls {
            let bytes = reqwest::get(url).await?.bytes().await?;
            std::fs::write(format!("evidence-{}.bin", f.id), &bytes)?;
        }
    }
}

Error handling deep-dive

Every non-2xx response carries the same envelope:

{
  "error": {
    "code": "insufficient_tokens",
    "message": "...",
    "request_id": "req_...",
    "required": 1200,
    "balance": 300,
    "top_up_url": "https://app.pentify.io/billing"
  }
}

The PentifyError enum in error.rs above maps each canonical code into a typed variant. Use thiserror for Display / std::error::Error ergonomics, and add From<reqwest::Error> so transport errors propagate with ?. Match by variant in callers — never parse code strings yourself:

match client.create_scan(&input, None).await {
    Ok(scan) => println!("started {}", scan.id),
    Err(PentifyError::InsufficientTokens { required, balance, top_up_url, .. }) => {
        eprintln!("need {required}, have {balance} -> {top_up_url}");
    }
    Err(PentifyError::RateLimited { retry_after_seconds, .. }) => {
        tokio::time::sleep(std::time::Duration::from_secs(retry_after_seconds)).await;
    }
    Err(PentifyError::TargetNotVerified { .. }) => { /* run /targets/:id/verify */ }
    Err(e) => return Err(e.into()),
}

See Errors for the canonical code list.