invoist 1.0.1

Official Rust client for the Invoist API: extract invoices, read a household's bills, verify webhooks.
Documentation
//! Verifying the webhooks Invoist sends.
//!
//! Every delivery is signed: the `X-Invoist-Signature` header carries a
//! timestamp and an HMAC-SHA256 of `<timestamp>.<raw body>` keyed with your
//! endpoint's secret. [`verify`] checks it and returns the event.
//!
//! **Pass the raw request body**, the exact bytes received. Parsing the JSON
//! and serialising it again changes whitespace and key order, and the
//! signature no longer matches.
//!
//! ```
//! use invoist::webhooks::{self, events};
//!
//! # let secret = "whsec_0123456789abcdef0123456789abcdef";
//! # let body = br#"{"id":"01a19812-0000-7000-8000-000000000001","type":"extraction.completed","created_at":"2026-11-01T10:40:00Z","data":{"document_id":"01a19812-0000-7000-8000-0000000000aa","status":"done"}}"#;
//! # let header = "t=1793520000,v1=8c71f5891ee0b4cdd53e4f7987dfd6ed098e11d52e38eec4eae26eb9ccf2d15d";
//! # let options = webhooks::VerifyOptions::default().now_unix(1793520000);
//! // `body` is the raw request body, `header` the X-Invoist-Signature header.
//! let event = webhooks::verify_with(body, header, secret, options)?;
//! if event.event_type == events::EXTRACTION_COMPLETED {
//!     println!("document {} is read", event.data["document_id"]);
//! }
//! # Ok::<(), invoist::Error>(())
//! ```

use std::time::{Duration, SystemTime, UNIX_EPOCH};

use hmac::{Hmac, KeyInit, Mac};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use subtle::ConstantTimeEq;

use crate::error::{Error, codes};

/// The header carrying the signature: `t=<unix seconds>,v1=<hex HMAC>`.
pub const SIGNATURE_HEADER: &str = "X-Invoist-Signature";
/// The header carrying the event type, such as `extraction.completed`.
pub const EVENT_HEADER: &str = "X-Invoist-Event";
/// The header carrying the delivery's ID, the same on every retry of one
/// delivery: use it to ignore repeats.
pub const DELIVERY_HEADER: &str = "X-Invoist-Delivery";
/// How far a signature's timestamp may be from now, either way.
pub const DEFAULT_TOLERANCE: Duration = Duration::from_secs(300);

/// The event types Invoist sends.
pub mod events {
    /// A document was added.
    pub const DOCUMENT_CREATED: &str = "document.created";
    /// A document was read successfully.
    pub const EXTRACTION_COMPLETED: &str = "extraction.completed";
    /// Reading a document failed.
    pub const EXTRACTION_FAILED: &str = "extraction.failed";
    /// Something was flagged for a person to check.
    pub const REVIEW_ITEM_CREATED: &str = "review_item.created";
    /// A flagged item was resolved or dismissed.
    pub const REVIEW_ITEM_RESOLVED: &str = "review_item.resolved";
    /// Someone sent a document that was asked for.
    pub const DOCUMENT_REQUEST_FULFILLED: &str = "document_request.fulfilled";
    /// Every event type above.
    pub const ALL: [&str; 6] = [
        DOCUMENT_CREATED,
        EXTRACTION_COMPLETED,
        EXTRACTION_FAILED,
        REVIEW_ITEM_CREATED,
        REVIEW_ITEM_RESOLVED,
        DOCUMENT_REQUEST_FULFILLED,
    ];
}

/// A verified webhook event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Event {
    /// The event's ID.
    pub id: String,
    /// What happened: one of [`events`], or a type added since this SDK.
    #[serde(rename = "type")]
    pub event_type: String,
    /// When it happened (RFC 3339, UTC).
    pub created_at: String,
    /// The event's details, which depend on its type.
    #[serde(default)]
    pub data: serde_json::Value,
}

impl Event {
    /// Reads [`data`](Event::data) into your own type.
    pub fn data_as<T: DeserializeOwned>(&self) -> Result<T, Error> {
        T::deserialize(&self.data).map_err(|e| {
            Error::new(
                400,
                codes::INVALID_PAYLOAD,
                format!("The event's data has an unexpected shape: {e}"),
            )
            .with_source(e)
        })
    }
}

/// Options for [`verify_with`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifyOptions {
    /// How far the signature's timestamp may be from now, in either
    /// direction. [`DEFAULT_TOLERANCE`] by default; zero disables the check.
    pub tolerance: Duration,
    /// The time to check against. `None` (the default) means now; set it in
    /// tests.
    pub now: Option<SystemTime>,
}

impl Default for VerifyOptions {
    fn default() -> Self {
        VerifyOptions {
            tolerance: DEFAULT_TOLERANCE,
            now: None,
        }
    }
}

impl VerifyOptions {
    /// Sets [`tolerance`](VerifyOptions::tolerance).
    pub fn tolerance(mut self, tolerance: Duration) -> Self {
        self.tolerance = tolerance;
        self
    }

    /// Sets [`now`](VerifyOptions::now) as Unix seconds.
    pub fn now_unix(mut self, seconds: u64) -> Self {
        self.now = Some(UNIX_EPOCH + Duration::from_secs(seconds));
        self
    }
}

/// Verifies a webhook's `X-Invoist-Signature` header and returns its event,
/// allowing [`DEFAULT_TOLERANCE`] of clock difference.
///
/// `payload` must be the raw request body. Fails with status 400 and code
/// `invalid_signature` (missing or malformed header, or no match) or
/// `signature_too_old` (timestamp outside the window).
pub fn verify(
    payload: impl AsRef<[u8]>,
    signature_header: &str,
    secret: &str,
) -> Result<Event, Error> {
    verify_with(payload, signature_header, secret, VerifyOptions::default())
}

/// [`verify`] with a custom tolerance or clock.
pub fn verify_with(
    payload: impl AsRef<[u8]>,
    signature_header: &str,
    secret: &str,
    options: VerifyOptions,
) -> Result<Event, Error> {
    let payload = payload.as_ref();
    let header = parse_header(signature_header)?;

    let expected = signature(secret, header.timestamp, payload);

    // Check every v1 (several appear while a secret rotates) without
    // stopping at the first match, each comparison in constant time.
    let matched = header
        .signatures
        .iter()
        .fold(subtle::Choice::from(0), |acc, sig| {
            acc | sig.as_bytes().ct_eq(expected.as_bytes())
        });
    if !bool::from(matched) {
        return Err(invalid("The signature does not match the payload"));
    }

    if !options.tolerance.is_zero() {
        let now = options
            .now
            .unwrap_or_else(SystemTime::now)
            .duration_since(UNIX_EPOCH)
            .map_or(0, |d| d.as_secs());
        if now.abs_diff(header.seconds) > options.tolerance.as_secs() {
            return Err(Error::new(
                400,
                codes::SIGNATURE_TOO_OLD,
                "The signature's timestamp is outside the tolerance window",
            ));
        }
    }

    serde_json::from_slice(payload).map_err(|e| {
        Error::new(
            400,
            codes::INVALID_PAYLOAD,
            format!("The webhook body is not an event: {e}"),
        )
        .with_source(e)
    })
}

struct Header<'a> {
    timestamp: &'a str,
    seconds: u64,
    signatures: Vec<&'a str>,
}

fn parse_header(header: &str) -> Result<Header<'_>, Error> {
    let mut timestamp = None;
    let mut signatures = Vec::new();
    for part in header.split(',') {
        let Some((key, value)) = part.trim().split_once('=') else {
            continue;
        };
        match key.trim() {
            "t" if timestamp.is_some() => {
                return Err(invalid("The signature header has more than one timestamp"));
            }
            "t" => timestamp = Some(value.trim()),
            "v1" => signatures.push(value.trim()),
            _ => {}
        }
    }
    let timestamp = timestamp.ok_or_else(|| invalid("The signature header has no timestamp"))?;
    if timestamp.is_empty() || !timestamp.bytes().all(|b| b.is_ascii_digit()) {
        return Err(invalid("The signature timestamp is not a number"));
    }
    let seconds = timestamp
        .parse()
        .map_err(|_| invalid("The signature timestamp is not a number"))?;
    if signatures.is_empty() {
        return Err(invalid("The signature header has no v1 signature"));
    }
    Ok(Header {
        timestamp,
        seconds,
        signatures,
    })
}

fn invalid(message: &str) -> Error {
    Error::new(400, codes::INVALID_SIGNATURE, message)
}

fn hex(bytes: &[u8]) -> String {
    const DIGITS: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        out.push(DIGITS[(b >> 4) as usize] as char);
        out.push(DIGITS[(b & 0x0f) as usize] as char);
    }
    out
}

/// Signs a payload the way Invoist does. For tests of your own webhook
/// handler: build a header your handler will accept.
///
/// ```
/// use invoist::webhooks;
///
/// let body = br#"{"id":"evt_1","type":"document.created","created_at":"2026-11-01T10:40:00Z","data":{}}"#;
/// let header = webhooks::sign(body, "whsec_test", 1793520000);
/// let options = webhooks::VerifyOptions::default().now_unix(1793520000);
/// let event = webhooks::verify_with(body, &header, "whsec_test", options)?;
/// assert_eq!(event.event_type, webhooks::events::DOCUMENT_CREATED);
/// # Ok::<(), invoist::Error>(())
/// ```
pub fn sign(payload: impl AsRef<[u8]>, secret: &str, timestamp: u64) -> String {
    let timestamp = timestamp.to_string();
    let v1 = signature(secret, &timestamp, payload.as_ref());
    format!("t={timestamp},v1={v1}")
}

/// The lowercase hex HMAC-SHA256 of `<timestamp>.<payload>`.
fn signature(secret: &str, timestamp: &str, payload: &[u8]) -> String {
    let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(secret.as_bytes())
        .expect("HMAC accepts keys of any length");
    mac.update(timestamp.as_bytes());
    mac.update(b".");
    mac.update(payload);
    hex(&mac.finalize().into_bytes())
}