use crate::http::Error;
use crate::ids::UserId;
use crate::lenient::{array, boolean, get, int, string, uint};
use base64::Engine;
use rsa::pkcs1::DecodeRsaPublicKey;
use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::Verifier;
use serde::Serialize;
use serde_json::Value;
use std::fmt;
pub const DEFAULT_TOLERANCE_SECS: u64 = 300;
const NAMES: [&str; 6] = [
"kick-event-message-id",
"kick-event-message-timestamp",
"kick-event-signature",
"kick-event-type",
"kick-event-version",
"kick-event-subscription-id",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Headers {
pub message_id: String,
pub timestamp: String,
pub signature: String,
pub event_type: String,
pub version: String,
pub subscription_id: Option<String>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum WebhookError {
#[error("missing header {0}")]
MissingHeader(&'static str),
#[error("malformed header {0}")]
MalformedHeader(&'static str),
#[error("unreadable public key")]
BadKey,
#[error("malformed signature")]
MalformedSignature,
#[error("signature does not match")]
Mismatch,
#[error("the delivery's timestamp is {offset_secs} s from now, beyond the tolerance")]
Stale {
offset_secs: i64,
},
}
impl Headers {
pub fn from_pairs<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> Result<Self, WebhookError> {
let mut found: [Option<String>; 6] = Default::default();
for (name, value) in pairs {
if let Some(i) = NAMES.iter().position(|n| n.eq_ignore_ascii_case(name)) {
found[i] = Some(value.to_owned());
}
}
let [id, ts, sig, ty, ver, sub] = found;
let headers = Headers {
message_id: id.ok_or(WebhookError::MissingHeader(NAMES[0]))?,
timestamp: ts.ok_or(WebhookError::MissingHeader(NAMES[1]))?,
signature: sig.ok_or(WebhookError::MissingHeader(NAMES[2]))?,
event_type: ty.ok_or(WebhookError::MissingHeader(NAMES[3]))?,
version: ver.ok_or(WebhookError::MissingHeader(NAMES[4]))?,
subscription_id: sub,
};
headers.check()?;
Ok(headers)
}
fn check(&self) -> Result<i64, WebhookError> {
if !is_ulid(&self.message_id) {
return Err(WebhookError::MalformedHeader(NAMES[0]));
}
rfc3339_secs(&self.timestamp).ok_or(WebhookError::MalformedHeader(NAMES[1]))
}
}
fn is_ulid(s: &str) -> bool {
s.len() == 26
&& s.bytes().enumerate().all(|(i, b)| {
let b = b.to_ascii_uppercase();
match i {
0 => (b'0'..=b'7').contains(&b),
_ => b.is_ascii_digit() || (b.is_ascii_uppercase() && !matches!(b, b'I' | b'L' | b'O' | b'U')),
}
})
}
fn rfc3339_secs(s: &str) -> Option<i64> {
let b = s.as_bytes();
let num = |from: usize, len: usize| -> Option<i64> {
let part = b.get(from..from + len)?;
part.iter().all(u8::is_ascii_digit).then(|| part.iter().fold(0i64, |n, d| n * 10 + i64::from(d - b'0')))
};
let sep = |at: usize, c: &[u8]| b.get(at).is_some_and(|x| c.contains(x));
if !(sep(4, b"-") && sep(7, b"-") && sep(10, b"Tt") && sep(13, b":") && sep(16, b":")) {
return None;
}
let (year, month, day) = (num(0, 4)?, num(5, 2)?, num(8, 2)?);
let (hour, minute, second) = (num(11, 2)?, num(14, 2)?, num(17, 2)?);
let mut rest = 19;
if sep(rest, b".") {
let digits = b[rest + 1..].iter().take_while(|d| d.is_ascii_digit()).count();
if digits == 0 {
return None;
}
rest += 1 + digits;
}
let offset = match b.get(rest..)? {
[b'Z' | b'z'] => 0,
[sign @ (b'+' | b'-'), _, _, b':', _, _] => {
let (h, m) = (num(rest + 1, 2)?, num(rest + 4, 2)?);
if h > 23 || m > 59 {
return None;
}
(h * 60 + m) * 60 * if *sign == b'-' { -1 } else { 1 }
}
_ => return None,
};
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
let days_in_month = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return None,
};
if day == 0 || day > days_in_month || hour > 23 || minute > 59 || second > 60 {
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + hour * 3_600 + minute * 60 + second - offset)
}
#[derive(Clone)]
pub struct PublicKey(VerifyingKey<sha2::Sha256>);
impl PartialEq for PublicKey {
fn eq(&self, other: &Self) -> bool {
AsRef::<rsa::RsaPublicKey>::as_ref(&self.0) == other.0.as_ref()
}
}
impl Eq for PublicKey {}
impl PublicKey {
pub fn from_pem(pem: &str) -> Result<Self, WebhookError> {
let pem = pem.trim();
let key = rsa::RsaPublicKey::from_public_key_pem(pem)
.or_else(|_| rsa::RsaPublicKey::from_pkcs1_pem(pem))
.map_err(|_| WebhookError::BadKey)?;
Ok(Self(VerifyingKey::new(key)))
}
}
impl std::str::FromStr for PublicKey {
type Err = WebhookError;
fn from_str(pem: &str) -> Result<Self, WebhookError> {
Self::from_pem(pem)
}
}
impl fmt::Debug for PublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("PublicKey(RSA)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Freshness {
Within {
now: u64,
tolerance_secs: u64,
},
Skip,
}
impl Freshness {
pub fn at(now: u64) -> Self {
Freshness::Within { now, tolerance_secs: DEFAULT_TOLERANCE_SECS }
}
}
pub fn verify(
key: &PublicKey,
headers: &Headers,
body: impl AsRef<[u8]>,
freshness: Freshness,
) -> Result<(), WebhookError> {
let timestamp = headers.check()?;
if let Freshness::Within { now, tolerance_secs } = freshness {
let offset = i128::from(timestamp) - i128::from(now);
if offset.unsigned_abs() > u128::from(tolerance_secs) {
let offset_secs = i64::try_from(offset).unwrap_or(if offset < 0 { i64::MIN } else { i64::MAX });
return Err(WebhookError::Stale { offset_secs });
}
}
let raw = base64::engine::general_purpose::STANDARD
.decode(headers.signature.trim())
.map_err(|_| WebhookError::MalformedSignature)?;
let signature = Signature::try_from(raw.as_slice()).map_err(|_| WebhookError::MalformedSignature)?;
let body = body.as_ref();
let mut signed = Vec::with_capacity(headers.message_id.len() + headers.timestamp.len() + body.len() + 2);
signed.extend_from_slice(headers.message_id.as_bytes());
signed.push(b'.');
signed.extend_from_slice(headers.timestamp.as_bytes());
signed.push(b'.');
signed.extend_from_slice(body);
key.0.verify(&signed, &signature).map_err(|_| WebhookError::Mismatch)
}
pub fn verify_pem(
public_key_pem: &str,
headers: &Headers,
body: impl AsRef<[u8]>,
freshness: Freshness,
) -> Result<(), WebhookError> {
verify(&PublicKey::from_pem(public_key_pem)?, headers, body, freshness)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Person {
pub user_id: Option<UserId>,
pub username: Option<String>,
pub channel_slug: Option<String>,
pub is_verified: Option<bool>,
pub is_anonymous: Option<bool>,
pub profile_picture: Option<String>,
}
impl Person {
fn from(v: Option<&Value>) -> Option<Self> {
let v = v?;
Some(Person {
user_id: uint(v, "user_id").map(UserId),
username: string(v, "username"),
channel_slug: string(v, "channel_slug"),
is_verified: boolean(v, "is_verified"),
is_anonymous: boolean(v, "is_anonymous"),
profile_picture: string(v, "profile_picture"),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Category {
pub id: Option<u64>,
pub name: Option<String>,
pub thumbnail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum WebhookEvent {
LivestreamStatus {
broadcaster: Option<Person>,
is_live: bool,
title: Option<String>,
started_at: Option<String>,
ended_at: Option<String>,
},
LivestreamMetadata {
broadcaster: Option<Person>,
title: Option<String>,
language: Option<String>,
has_mature_content: Option<bool>,
category: Option<Category>,
},
Followed {
broadcaster: Option<Person>,
follower: Option<Person>,
},
Subscription {
renewal: bool,
broadcaster: Option<Person>,
subscriber: Option<Person>,
duration: Option<i64>,
created_at: Option<String>,
expires_at: Option<String>,
},
SubscriptionGifts {
broadcaster: Option<Person>,
gifter: Option<Person>,
giftees: Vec<Person>,
created_at: Option<String>,
expires_at: Option<String>,
},
KicksGifted {
broadcaster: Option<Person>,
sender: Option<Person>,
amount: Option<i64>,
name: Option<String>,
kind: Option<String>,
tier: Option<String>,
message: Option<String>,
pinned_time_seconds: Option<i64>,
created_at: Option<String>,
},
Other {
event_type: String,
body: Value,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shape {
Status,
Metadata,
Followed,
Subscription,
Gifts,
Kicks,
}
impl Shape {
fn of_type(event_type: &str) -> Option<Shape> {
Some(match event_type {
"livestream.status.updated" => Shape::Status,
"livestream.metadata.updated" => Shape::Metadata,
"channel.followed" => Shape::Followed,
"channel.subscription.new" | "channel.subscription.renewal" => Shape::Subscription,
"channel.subscription.gifts" => Shape::Gifts,
"kicks.gifted" => Shape::Kicks,
_ => return None,
})
}
fn fits(self, v: &Value) -> bool {
let object = |key| get(v, key).is_some_and(Value::is_object);
match self {
Shape::Status => boolean(v, "is_live").is_some(),
Shape::Metadata => get(v, "metadata").is_some_and(|m| {
m.is_object()
&& ["title", "language", "has_mature_content", "category"].iter().any(|k| m.get(k).is_some())
}),
Shape::Followed => object("follower"),
Shape::Subscription => object("subscriber"),
Shape::Gifts => get(v, "giftees").is_some_and(Value::is_array),
Shape::Kicks => object("gift"),
}
}
}
pub fn parse(event_type: &str, body: impl AsRef<[u8]>) -> Result<WebhookEvent, Error> {
let v: Value = serde_json::from_slice(body.as_ref())?;
if !v.is_object() {
return Err(Error::shape("a webhook body is an object"));
}
if let Some(claimed) = Shape::of_type(event_type)
&& !claimed.fits(&v)
{
return Err(Error::shape(format!("the body is not a {event_type}")));
}
let broadcaster = || Person::from(get(&v, "broadcaster"));
Ok(match event_type {
"livestream.status.updated" => WebhookEvent::LivestreamStatus {
broadcaster: broadcaster(),
is_live: boolean(&v, "is_live").unwrap_or(false),
title: string(&v, "title"),
started_at: string(&v, "started_at"),
ended_at: string(&v, "ended_at"),
},
"livestream.metadata.updated" => {
let m = get(&v, "metadata").cloned().unwrap_or_default();
let c = get(&m, "category").or_else(|| get(&m, "Category"));
WebhookEvent::LivestreamMetadata {
broadcaster: broadcaster(),
title: string(&m, "title"),
language: string(&m, "language"),
has_mature_content: boolean(&m, "has_mature_content"),
category: c.map(|c| Category {
id: uint(c, "id"),
name: string(c, "name"),
thumbnail: string(c, "thumbnail"),
}),
}
}
"channel.followed" => {
WebhookEvent::Followed { broadcaster: broadcaster(), follower: Person::from(get(&v, "follower")) }
}
"channel.subscription.new" | "channel.subscription.renewal" => WebhookEvent::Subscription {
renewal: event_type.ends_with("renewal"),
broadcaster: broadcaster(),
subscriber: Person::from(get(&v, "subscriber")),
duration: int(&v, "duration"),
created_at: string(&v, "created_at"),
expires_at: string(&v, "expires_at"),
},
"channel.subscription.gifts" => WebhookEvent::SubscriptionGifts {
broadcaster: broadcaster(),
gifter: Person::from(get(&v, "gifter")),
giftees: array(&v, "giftees").iter().filter_map(|g| Person::from(Some(g))).collect(),
created_at: string(&v, "created_at"),
expires_at: string(&v, "expires_at"),
},
"kicks.gifted" => {
let g = get(&v, "gift").cloned().unwrap_or_default();
WebhookEvent::KicksGifted {
broadcaster: broadcaster(),
sender: Person::from(get(&v, "sender")),
amount: int(&g, "amount"),
name: string(&g, "name"),
kind: string(&g, "type"),
tier: string(&g, "tier"),
message: string(&g, "message"),
pinned_time_seconds: int(&g, "pinned_time_seconds"),
created_at: string(&v, "created_at"),
}
}
other => WebhookEvent::Other { event_type: other.to_owned(), body: v },
})
}
#[cfg(test)]
mod tests {
use super::*;
const ID: &str = "01M38M9K3YRKV8E6Q68C18ZWG4";
#[test]
fn headers_in_any_case_and_missing_ones_named() {
let pairs = [
("Kick-Event-Message-Id", ID),
("kick-event-message-timestamp", "2026-09-24T02:35:42Z"),
("KICK-EVENT-SIGNATURE", "c2ln"),
("kick-event-type", "channel.followed"),
("kick-event-version", "1"),
];
let h = Headers::from_pairs(pairs).unwrap();
assert_eq!(h.event_type, "channel.followed");
assert_eq!(h.subscription_id, None);
assert_eq!(
Headers::from_pairs(pairs[1..].iter().copied()),
Err(WebhookError::MissingHeader("kick-event-message-id"))
);
}
#[test]
fn signed_headers_must_be_in_their_formats() {
let with = |id: &str, ts: &str| {
Headers::from_pairs([
("kick-event-message-id", id),
("kick-event-message-timestamp", ts),
("kick-event-signature", "c2ln"),
("kick-event-type", "channel.followed"),
("kick-event-version", "1"),
])
};
assert!(with(ID, "2026-09-24T02:35:42Z").is_ok());
assert!(with(&ID.to_lowercase(), "2026-09-24t02:35:42.123456+02:00").is_ok());
let bad_id = Err(WebhookError::MalformedHeader("kick-event-message-id"));
for id in [
"",
"01M",
"01M38M9K3YRKV8E6Q68C18ZWG4X",
"81M38M9K3YRKV8E6Q68C18ZWG4",
"01M38M9K3YRKV8E6Q68C18ZWG.",
"01M38M9K3YRKV8E6Q68C18ZWGI",
] {
assert_eq!(with(id, "2026-09-24T02:35:42Z"), bad_id, "{id:?}");
}
let bad_ts = Err(WebhookError::MalformedHeader("kick-event-message-timestamp"));
for ts in [
"",
"2026-09-24T02:35:42",
"2026-09-24T02:35:42Z.",
"2026-09-24T02:35:42Z.x",
"2026-09-24 02:35:42Z",
"2026-02-30T02:35:42Z",
"2026-09-24T24:00:00Z",
"2026-09-24T02:35:42.Z",
"2026-09-24T02:35:42+0200",
"1758681342",
] {
assert_eq!(with(ID, ts), bad_ts, "{ts:?}");
}
}
#[test]
fn rfc3339_as_unix_seconds() {
assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(rfc3339_secs("2026-09-24T02:35:42Z"), Some(1_790_217_342));
assert_eq!(rfc3339_secs("2026-09-24T04:35:42.5+02:00"), Some(1_790_217_342));
assert_eq!(rfc3339_secs("2024-02-29T00:00:00Z"), Some(1_709_164_800));
assert_eq!(rfc3339_secs("2023-02-29T00:00:00Z"), None);
assert_eq!(rfc3339_secs("1969-12-31T23:59:59Z"), Some(-1));
}
#[test]
fn a_body_must_be_its_types_shape() {
let follow = r#"{"broadcaster":{"user_id":1},"follower":{"user_id":2}}"#;
assert!(matches!(parse("channel.followed", follow), Ok(WebhookEvent::Followed { .. })));
assert!(matches!(parse("channel.followed", follow.as_bytes()), Ok(WebhookEvent::Followed { .. })));
for other in [
"livestream.status.updated",
"livestream.metadata.updated",
"channel.subscription.new",
"channel.subscription.gifts",
"kicks.gifted",
] {
assert!(matches!(parse(other, follow), Err(Error::Shape(_))), "{other}");
}
let banned = r#"{"broadcaster":{},"moderator":{},"banned_user":{},"metadata":{"reason":"x"}}"#;
assert!(matches!(parse("livestream.metadata.updated", banned), Err(Error::Shape(_))));
assert!(matches!(parse("moderation.banned", banned), Ok(WebhookEvent::Other { .. })));
assert!(matches!(parse("channel.followed", "[]"), Err(Error::Shape(_))));
assert!(matches!(parse("channel.followed", "{"), Err(Error::Json(_))));
assert!(matches!(parse("channel.followed", b"\xff"), Err(Error::Json(_))));
}
const BODIES: [(&str, &str); 7] = [
(
"livestream.status.updated",
r#"{"broadcaster":{"user_id":1},"is_live":true,"title":"t","started_at":"2026-09-24T03:01:11Z","ended_at":null}"#,
),
(
"livestream.metadata.updated",
r#"{"broadcaster":{"user_id":1},"metadata":{"title":"t","language":"en","has_mature_content":false,"category":{"id":15},"Category":{"id":15}}}"#,
),
("channel.followed", r#"{"broadcaster":{"user_id":1},"follower":{"user_id":2}}"#),
(
"channel.subscription.new",
r#"{"broadcaster":{"user_id":1},"subscriber":{"user_id":2},"duration":1,"created_at":"x","expires_at":"y"}"#,
),
(
"channel.subscription.renewal",
r#"{"broadcaster":{"user_id":1},"subscriber":{"user_id":2},"duration":3,"created_at":"x","expires_at":"y"}"#,
),
(
"channel.subscription.gifts",
r#"{"broadcaster":{"user_id":1},"gifter":{"is_anonymous":true,"user_id":null},"giftees":[{"user_id":3}],"created_at":"x","expires_at":"y"}"#,
),
(
"kicks.gifted",
r#"{"broadcaster":{"user_id":1},"sender":{"user_id":2},"gift":{"amount":10,"name":"n","type":"BASIC","tier":"BASIC","message":"m","pinned_time_seconds":0},"created_at":"x"}"#,
),
];
#[test]
fn no_known_body_passes_as_another_known_type() {
let subscription = |t: &str| t.starts_with("channel.subscription.") && !t.ends_with("gifts");
for (own, body) in BODIES {
assert!(parse(own, body).is_ok(), "{own}");
for (other, _) in BODIES {
if other == own || (subscription(own) && subscription(other)) {
continue;
}
assert!(matches!(parse(other, body), Err(Error::Shape(_))), "a {own} passed as a {other}");
}
}
for body in [
r#"{"broadcaster":{},"moderator":{},"banned_user":{},"metadata":{"reason":"x","created_at":"x","expires_at":null}}"#,
r#"{"id":"r","user_input":"u","status":"accepted","redeemed_at":"x","reward":{"id":"1","title":"t","cost":5},"redeemer":{},"broadcaster":{}}"#,
r#"{"message_id":"m","replies_to":null,"broadcaster":{},"sender":{},"content":"hi","emotes":[],"created_at":"x"}"#,
] {
for (claimed, _) in BODIES {
assert!(matches!(parse(claimed, body), Err(Error::Shape(_))), "{claimed}: {body}");
}
}
}
#[test]
fn fields_kick_adds_later_are_ignored() {
let status = r#"{"broadcaster":{},"is_live":true,"started_at":"x","follower":{},"gift":{},"new_field":[1]}"#;
assert!(matches!(
parse("livestream.status.updated", status),
Ok(WebhookEvent::LivestreamStatus { is_live: true, .. })
));
let follow = r#"{"broadcaster":{"user_id":1},"follower":{"user_id":2},"followed_at":"2026-10-01T00:00:00Z"}"#;
assert!(matches!(parse("channel.followed", follow), Ok(WebhookEvent::Followed { .. })));
}
const TEST_PRIVATE_KEY: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures/signing/throwaway-test-key.pem"));
fn signed(id: &str, ts: &str, body: &[u8]) -> (PublicKey, Headers) {
use rsa::pkcs8::DecodePrivateKey;
use rsa::signature::{SignatureEncoding, Signer};
let private = rsa::RsaPrivateKey::from_pkcs8_pem(TEST_PRIVATE_KEY).unwrap();
let public = PublicKey(VerifyingKey::new(private.to_public_key()));
let mut text = format!("{id}.{ts}.").into_bytes();
text.extend_from_slice(body);
let signature = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(private).sign(&text).to_vec();
let signature = base64::engine::general_purpose::STANDARD.encode(signature);
let headers = Headers::from_pairs([
("kick-event-message-id", id),
("kick-event-message-timestamp", ts),
("kick-event-signature", signature.as_str()),
("kick-event-type", "channel.followed"),
("kick-event-version", "1"),
])
.unwrap();
(public, headers)
}
#[test]
fn bodies_verify_byte_for_byte_and_fresh() {
let at = 1_790_217_342; let body = b"{\"x\":\"\xff\xfe\"}";
let (key, headers) = signed(ID, "2026-09-24T02:35:42Z", body);
assert_eq!(verify(&key, &headers, body, Freshness::at(at)), Ok(()));
assert_eq!(verify(&key, &headers, Vec::from(&body[..]), Freshness::at(at + 300)), Ok(()));
assert_eq!(verify(&key, &headers, body, Freshness::at(at - 300)), Ok(()));
assert_eq!(
verify(&key, &headers, body, Freshness::at(at + 301)),
Err(WebhookError::Stale { offset_secs: -301 })
);
assert_eq!(
verify(&key, &headers, body, Freshness::at(at - 301)),
Err(WebhookError::Stale { offset_secs: 301 })
);
let tight = Freshness::Within { now: at + 10, tolerance_secs: 5 };
assert_eq!(verify(&key, &headers, body, tight), Err(WebhookError::Stale { offset_secs: -10 }));
assert_eq!(verify(&key, &headers, body, Freshness::Skip), Ok(()));
assert_eq!(verify(&key, &headers, b"{}", Freshness::Skip), Err(WebhookError::Mismatch));
let mut moved = headers.clone();
moved.timestamp = "2026-09-24T02:35:42Z.{".into();
assert_eq!(
verify(&key, &moved, body, Freshness::Skip),
Err(WebhookError::MalformedHeader("kick-event-message-timestamp"))
);
let mut moved = headers.clone();
moved.message_id = format!("{ID}.2026");
assert_eq!(
verify(&key, &moved, body, Freshness::Skip),
Err(WebhookError::MalformedHeader("kick-event-message-id"))
);
let mut garbled = headers;
garbled.signature = "not base64!".into();
assert_eq!(verify(&key, &garbled, body, Freshness::Skip), Err(WebhookError::MalformedSignature));
}
#[test]
fn keys_are_read_once() {
assert_eq!(PublicKey::from_pem("nope"), Err(WebhookError::BadKey));
assert_eq!("nope".parse::<PublicKey>(), Err(WebhookError::BadKey));
}
}