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.
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
reqwestclient. 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=trueGenerated 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.