diff --git a/CHANGELOG.md b/CHANGELOG.md
index 775f3248c9..6efca2b2dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 
 ### Features
 
+- Native iOS events are now exposed to the dotnet layer for users to hook through SentryOptions.BeforeSend and SentryOptions.OnCrashedLastRun ([#2102](https://github.com/getsentry/sentry-dotnet/pull/3958))
 - Users can now register their own MAUI controls for breadcrumb creation ([#3997](https://github.com/getsentry/sentry-dotnet/pull/3997))
 - Serilog scope properties are now sent with Sentry events ([#3976](https://github.com/getsentry/sentry-dotnet/pull/3976))
 - The sample seed used for sampling decisions is now propagated, for use in downstream custom trace samplers ([#3951](https://github.com/getsentry/sentry-dotnet/pull/3951))
diff --git a/modules/Ben.Demystifier b/modules/Ben.Demystifier
index fe04751006..0a6b30f6ac 160000
--- a/modules/Ben.Demystifier
+++ b/modules/Ben.Demystifier
@@ -1 +1 @@
-Subproject commit fe0475100614508fff1c76aa896e17807adde83f
+Subproject commit 0a6b30f6ac6892ff45b9dcd5b07d710e5228256e
diff --git a/modules/sentry-native b/modules/sentry-native
index 2a3bdfb9e9..ccef7125b3 160000
--- a/modules/sentry-native
+++ b/modules/sentry-native
@@ -1 +1 @@
-Subproject commit 2a3bdfb9e9899574ee2558df2eea39dfa097f103
+Subproject commit ccef7125b3783210f44ee6b100df7278e6ba3eff
diff --git a/samples/Sentry.Samples.Ios/AppDelegate.cs b/samples/Sentry.Samples.Ios/AppDelegate.cs
index 80e9ea09a5..2d50a320c3 100644
--- a/samples/Sentry.Samples.Ios/AppDelegate.cs
+++ b/samples/Sentry.Samples.Ios/AppDelegate.cs
@@ -28,6 +28,17 @@ public override bool FinishedLaunching(UIApplication application, NSDictionary l
             options.Native.EnableAppHangTracking = true;
 
             options.CacheDirectoryPath = Path.GetTempPath();
+
+            options.SetBeforeSend((evt, _) =>
+            {
+                evt.SetTag("dotnet-iOS-Native-Before", "Hello World");
+                return evt;
+            });
+
+            options.OnCrashedLastRun = e =>
+            {
+                Console.WriteLine(e);
+            };
         });
 
         // create a new window instance based on the screen size
diff --git a/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj b/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj
index 76b122d042..b1820343e6 100644
--- a/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj
+++ b/samples/Sentry.Samples.Ios/Sentry.Samples.Ios.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFramework>net9.0-ios18.0</TargetFramework>
+    <TargetFramework>net9.0-ios</TargetFramework>
     <OutputType>Exe</OutputType>
     <Nullable>enable</Nullable>
     <ImplicitUsings>true</ImplicitUsings>
diff --git a/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs b/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs
index 3bbfc298dc..77ad1d5797 100644
--- a/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs
+++ b/src/Sentry.Bindings.Cocoa/ApiDefinitions.cs
@@ -27,7 +27,6 @@ namespace Sentry.CocoaSdk;
 
 // typedef SentryEvent * _Nullable (^SentryBeforeSendEventCallback)(SentryEvent * _Nonnull);
 [Internal]
-[return: NullAllowed]
 delegate SentryEvent SentryBeforeSendEventCallback (SentryEvent @event);
 
 // typedef id<SentrySpan> _Nullable (^SentryBeforeSendSpanCallback)(id<SentrySpan> _Nonnull);
diff --git a/src/Sentry/Internal/Extensions/JsonExtensions.cs b/src/Sentry/Internal/Extensions/JsonExtensions.cs
index fa29909eac..8bc25eba83 100644
--- a/src/Sentry/Internal/Extensions/JsonExtensions.cs
+++ b/src/Sentry/Internal/Extensions/JsonExtensions.cs
@@ -213,6 +213,31 @@ public static void Deconstruct(this JsonProperty jsonProperty, out string name,
         return double.Parse(json.ToString()!, CultureInfo.InvariantCulture);
     }
 
+    /// <summary>
+    /// Safety value to deal with native serialization - allows datetimeoffset to come in as a long or string value
+    /// </summary>
+    /// <param name="json"></param>
+    /// <param name="propertyName"></param>
+    /// <returns></returns>
+    public static DateTimeOffset? GetSafeDateTimeOffset(this JsonElement json, string propertyName)
+    {
+        DateTimeOffset? result = null;
+        var dtRaw = json.GetPropertyOrNull(propertyName);
+        if (dtRaw != null)
+        {
+            if (dtRaw.Value.ValueKind == JsonValueKind.Number)
+            {
+                var epoch = Convert.ToInt64(dtRaw.Value.GetDouble());
+                result = DateTimeOffset.FromUnixTimeSeconds(epoch);
+            }
+            else
+            {
+                result = dtRaw.Value.GetDateTimeOffset();
+            }
+        }
+        return result;
+    }
+
     public static long? GetHexAsLong(this JsonElement json)
     {
         // If the address is in json as a number, we can just use it.
diff --git a/src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs b/src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs
index e0fb5e7bc0..cf6776a450 100644
--- a/src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs
+++ b/src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs
@@ -169,6 +169,24 @@ public static NSDictionary<NSString, NSObject> ToNSDictionary<TValue>(
                 d.Keys.ToArray());
     }
 
+    public static NSDictionary<NSString, NSString> ToNSDictionaryStrings(
+        this IEnumerable<KeyValuePair<string, string>> dict)
+    {
+        var d = new Dictionary<NSString, NSString>();
+        foreach (var item in dict)
+        {
+            if (item.Value != null)
+            {
+                d.Add((NSString)item.Key, new NSString(item.Value));
+            }
+        }
+
+        return NSDictionary<NSString, NSString>
+            .FromObjectsAndKeys(
+                d.Values.ToArray(),
+                d.Keys.ToArray());
+    }
+
     public static NSDictionary<NSString, NSObject>? ToNullableNSDictionary<TValue>(
         this ICollection<KeyValuePair<string, TValue>> dict) =>
         dict.Count == 0 ? null : dict.ToNSDictionary();
@@ -224,4 +242,126 @@ public static object ToObject(this NSNumber n)
                 $"NSNumber \"{n.StringValue}\" has an unknown ObjCType \"{n.ObjCType}\" (Class: \"{n.Class.Name}\")")
         };
     }
+
+    public static void CopyToCocoaSentryEvent(this SentryEvent managed, CocoaSdk.SentryEvent native)
+    {
+        // we only support a subset of mutated data to be passed back to the native SDK at this time
+        native.ServerName = managed.ServerName;
+        native.Dist = managed.Distribution;
+        native.Logger = managed.Logger;
+        native.ReleaseName = managed.Release;
+        native.Environment = managed.Environment;
+        native.Transaction = managed.TransactionName!;
+        native.Message = managed.Message?.ToCocoaSentryMessage();
+        native.Tags = managed.Tags?.ToNSDictionaryStrings();
+        native.Extra = managed.Extra?.ToNSDictionary();
+        native.Breadcrumbs = managed.Breadcrumbs?.Select(x => x.ToCocoaBreadcrumb()).ToArray();
+        native.User = managed.User?.ToCocoaUser();
+
+        if (managed.Level != null)
+        {
+            native.Level = managed.Level.Value.ToCocoaSentryLevel();
+        }
+
+        if (managed.Exception != null)
+        {
+            native.Error = new NSError(new NSString(managed.Exception.ToString()), IntPtr.Zero);
+        }
+    }
+
+    public static SentryEvent? ToSentryEvent(this CocoaSdk.SentryEvent sentryEvent)
+    {
+        using var stream = sentryEvent.ToJsonStream();
+        if (stream == null)
+            return null;
+
+        using var json = JsonDocument.Parse(stream);
+        var exception = sentryEvent.Error == null ? null : new NSErrorException(sentryEvent.Error);
+        var ev = SentryEvent.FromJson(json.RootElement, exception);
+        return ev;
+    }
+
+    public static CocoaSdk.SentryMessage ToCocoaSentryMessage(this SentryMessage msg)
+    {
+        var native = new CocoaSdk.SentryMessage(msg.Formatted ?? string.Empty);
+        native.Params = msg.Params?.Select(x => x.ToString()!).ToArray() ?? new string[0];
+
+        return native;
+    }
+
+    // not tested or needed yet - leaving for future just in case
+    // public static CocoaSdk.SentryThread ToCocoaSentryThread(this SentryThread thread)
+    // {
+    //     var id = NSNumber.FromInt32(thread.Id ?? 0);
+    //     var native = new CocoaSdk.SentryThread(id);
+    //     native.Crashed = thread.Crashed;
+    //     native.Current = thread.Current;
+    //     native.Name = thread.Name;
+    //     native.Stacktrace = thread.Stacktrace?.ToCocoaSentryStackTrace();
+    //     // native.IsMain = not in dotnet
+    //     return native;
+    // }
+    //
+    // public static CocoaSdk.SentryRequest ToCocoaSentryRequest(this SentryRequest request)
+    // {
+    //     var native = new CocoaSdk.SentryRequest();
+    //     native.Cookies = request.Cookies;
+    //     native.Headers = request.Headers?.ToNSDictionaryStrings();
+    //     native.Method = request.Method;
+    //     native.QueryString = request.QueryString;
+    //     native.Url = request.Url;
+    //
+    //     // native.BodySize does not exist in dotnet
+    //     return native;
+    // }
+    //
+
+    // public static CocoaSdk.SentryException ToCocoaSentryException(this SentryException ex)
+    // {
+    //     var native = new CocoaSdk.SentryException(ex.Value ?? string.Empty, ex.Type ?? string.Empty);
+    //     native.Module = ex.Module;
+    //     native.Mechanism = ex.Mechanism?.ToCocoaSentryMechanism();
+    //     native.Stacktrace = ex.Stacktrace?.ToCocoaSentryStackTrace();
+    //     // not part of native - ex.ThreadId;
+    //     return native;
+    // }
+    //
+    // public static CocoaSdk.SentryStacktrace ToCocoaSentryStackTrace(this SentryStackTrace stackTrace)
+    // {
+    //     var frames = stackTrace.Frames?.Select(x => x.ToCocoaSentryFrame()).ToArray() ?? new CocoaSdk.SentryFrame[0];
+    //     var native = new CocoaSdk.SentryStacktrace(frames, new NSDictionary<NSString, NSString>());
+    //     // native.Register & native.Snapshot missing in dotnet
+    //     return native;
+    // }
+    //
+    // public static CocoaSdk.SentryFrame ToCocoaSentryFrame(this SentryStackFrame frame)
+    // {
+    //     var native = new CocoaSdk.SentryFrame();
+    //     native.Module = frame.Module;
+    //     native.Package = frame.Package;
+    //     native.InstructionAddress = frame.InstructionAddress?.ToString();
+    //     native.Function = frame.Function;
+    //     native.Platform = frame.Platform;
+    //     native.ColumnNumber = frame.ColumnNumber;
+    //     native.FileName = frame.FileName;
+    //     native.InApp = frame.InApp;
+    //     native.ImageAddress = frame.ImageAddress?.ToString();
+    //     native.LineNumber = frame.LineNumber;
+    //     native.SymbolAddress = frame.SymbolAddress?.ToString();
+    //
+    //     // native.StackStart = doesn't exist in dotnet
+    //     return native;
+    // }
+    //
+    // public static CocoaSdk.SentryMechanism ToCocoaSentryMechanism(this Mechanism mechanism)
+    // {
+    //     var native = new CocoaSdk.SentryMechanism(mechanism.Type);
+    //     native.Synthetic = mechanism.Synthetic;
+    //     native.Handled = mechanism.Handled;
+    //     native.Desc = mechanism.Description;
+    //     native.HelpLink = mechanism.HelpLink;
+    //     native.Data = mechanism.Data?.ToNSDictionary();
+    //     // TODO: Meta does not currently translate in dotnet - native.Meta = null;
+    //     return native;
+    // }
 }
diff --git a/src/Sentry/Platforms/Cocoa/Extensions/EnumExtensions.cs b/src/Sentry/Platforms/Cocoa/Extensions/EnumExtensions.cs
index 3cb7df8c99..feb2a37e91 100644
--- a/src/Sentry/Platforms/Cocoa/Extensions/EnumExtensions.cs
+++ b/src/Sentry/Platforms/Cocoa/Extensions/EnumExtensions.cs
@@ -4,7 +4,16 @@ internal static class EnumExtensions
 {
     // These align, so we can just cast
     public static SentryLevel ToSentryLevel(this CocoaSdk.SentryLevel level) => (SentryLevel)level;
-    public static CocoaSdk.SentryLevel ToCocoaSentryLevel(this SentryLevel level) => (CocoaSdk.SentryLevel)level;
+
+    public static CocoaSdk.SentryLevel ToCocoaSentryLevel(this SentryLevel level) => level switch
+    {
+        SentryLevel.Debug => CocoaSdk.SentryLevel.Debug,
+        SentryLevel.Info => CocoaSdk.SentryLevel.Info,
+        SentryLevel.Warning => CocoaSdk.SentryLevel.Warning,
+        SentryLevel.Error => CocoaSdk.SentryLevel.Error,
+        SentryLevel.Fatal => CocoaSdk.SentryLevel.Fatal,
+        _ => throw new ArgumentOutOfRangeException(nameof(level), level, null)
+    };
 
     public static BreadcrumbLevel ToBreadcrumbLevel(this CocoaSdk.SentryLevel level) =>
         level switch
diff --git a/src/Sentry/Platforms/Cocoa/Extensions/SentryEventExtensions.cs b/src/Sentry/Platforms/Cocoa/Extensions/SentryEventExtensions.cs
deleted file mode 100644
index ebb7c735ba..0000000000
--- a/src/Sentry/Platforms/Cocoa/Extensions/SentryEventExtensions.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-// using Sentry.Extensibility;
-// using Sentry.Protocol.Envelopes;
-//
-// namespace Sentry.Cocoa.Extensions;
-//
-// internal static class SentryEventExtensions
-// {
-//     /*
-//      * These methods map between a SentryEvent and it's Cocoa counterpart by serializing as JSON into memory on one side,
-//      * then deserializing back to an object on the other side.  It is not expected to be performant, as this code is only
-//      * used when a BeforeSend option is set, and then only when an event is captured by the Cocoa SDK (which should be
-//      * relatively rare).
-//      *
-//      * This approach avoids having to write to/from methods for the entire object graph.  However, it's also important to
-//      * recognize that there's not necessarily a one-to-one mapping available on all objects (even through serialization)
-//      * between the two SDKs, so some optional details may be lost when roundtripping.  That's generally OK, as this is
-//      * still better than nothing.  If a specific part of the object graph becomes important to roundtrip, we can consider
-//      * updating the objects on either side.
-//      */
-//
-//     public static SentryEvent ToSentryEvent(this CocoaSdk.SentryEvent sentryEvent, SentryCocoaSdkOptions nativeOptions)
-//     {
-//         using var stream = sentryEvent.ToJsonStream()!;
-//         //stream.Seek(0, SeekOrigin.Begin); ??
-//
-//         using var json = JsonDocument.Parse(stream);
-//         var exception = sentryEvent.Error == null ? null : new NSErrorException(sentryEvent.Error);
-//         return SentryEvent.FromJson(json.RootElement, exception);
-//     }
-//
-//     public static CocoaSdk.SentryEvent ToCocoaSentryEvent(this SentryEvent sentryEvent, SentryOptions options, SentryCocoaSdkOptions nativeOptions)
-//     {
-//         var envelope = Envelope.FromEvent(sentryEvent);
-//
-//         using var stream = new MemoryStream();
-//         envelope.Serialize(stream, options.DiagnosticLogger);
-//         stream.Seek(0, SeekOrigin.Begin);
-//
-//         using var data = NSData.FromStream(stream)!;
-//         var cocoaEnvelope = CocoaSdk.PrivateSentrySDKOnly.EnvelopeWithData(data);
-//
-//         var cocoaEvent = (CocoaSdk.SentryEvent) cocoaEnvelope.Items[0];
-//         return cocoaEvent;
-//     }
-// }
diff --git a/src/Sentry/Platforms/Cocoa/SentryOptions.cs b/src/Sentry/Platforms/Cocoa/SentryOptions.cs
index 6b0e9587e6..91172d9938 100644
--- a/src/Sentry/Platforms/Cocoa/SentryOptions.cs
+++ b/src/Sentry/Platforms/Cocoa/SentryOptions.cs
@@ -197,7 +197,6 @@ internal NativeOptions(SentryOptions options)
         /// </remarks>
         public NSUrlSessionDelegate? UrlSessionDelegate { get; set; } = null;
 
-
         // ---------- Other ----------
 
         /// <summary>
diff --git a/src/Sentry/Platforms/Cocoa/SentrySdk.cs b/src/Sentry/Platforms/Cocoa/SentrySdk.cs
index dcc8e48006..8908cd78c0 100644
--- a/src/Sentry/Platforms/Cocoa/SentrySdk.cs
+++ b/src/Sentry/Platforms/Cocoa/SentrySdk.cs
@@ -86,29 +86,90 @@ private static void InitSentryCocoaSdk(SentryOptions options)
             }
         }
 
-        // TODO: Finish SentryEventExtensions to enable these
-
-        // if (options.Native.EnableBeforeSend && options.BeforeSend is { } beforeSend)
-        // {
-        //     nativeOptions.BeforeSend = evt =>
-        //     {
-        //         var sentryEvent = evt.ToSentryEvent(nativeOptions);
-        //         var result = beforeSend(sentryEvent)?.ToCocoaSentryEvent(options, nativeOptions);
-        //
-        //         // Note: Nullable result is allowed but delegate is generated incorrectly
-        //         // See https://github.com/xamarin/xamarin-macios/issues/15299#issuecomment-1201863294
-        //         return result!;
-        //     };
-        // }
-
-        // if (options.Native.OnCrashedLastRun is { } onCrashedLastRun)
-        // {
-        //     nativeOptions.OnCrashedLastRun = evt =>
-        //     {
-        //         var sentryEvent = evt.ToSentryEvent(nativeOptions);
-        //         onCrashedLastRun(sentryEvent);
-        //     };
-        // }
+        nativeOptions.BeforeSend = evt =>
+        {
+            // When we have an unhandled managed exception, we send that to Sentry twice - once managed and once native.
+            // The managed exception is what a .NET developer would expect, and it is sent by the Sentry.NET SDK
+            // But we also get a native SIGABRT since it crashed the application, which is sent by the Sentry Cocoa SDK.
+
+            // There should only be one exception on the event in this case
+            if (evt.Exceptions?.Length == 1)
+            {
+                // It will match the following characteristics
+                var ex = evt.Exceptions[0];
+
+                // Thankfully, sometimes we can see Xamarin's unhandled exception handler on the stack trace, so we can filter
+                // them out. Here is the function that calls abort(), which we will use as a filter:
+                // https://github.com/xamarin/xamarin-macios/blob/c55fbdfef95028ba03d0f7a35aebca03bd76f852/runtime/runtime.m#L1114-L1122
+                if (ex.Type == "SIGABRT" && ex.Value == "Signal 6, Code 0" &&
+                    ex.Stacktrace?.Frames.Any(f => f.Function == "xamarin_unhandled_exception_handler") is true)
+                {
+                    // Don't send it
+                    options.LogDebug("Discarded {0} error ({1}). Captured as  managed exception instead.", ex.Type,
+                        ex.Value);
+                    return null!;
+                }
+
+                // Similar workaround for NullReferenceExceptions. We don't have any easy way to know whether the
+                // exception is managed code (compiled to native) or original native code though.
+                // See: https://github.com/getsentry/sentry-dotnet/issues/3776
+                if (ex.Type == "EXC_BAD_ACCESS")
+                {
+                    // Don't send it
+                    options.LogDebug("Discarded {0} error ({1}). Captured as  managed exception instead.", ex.Type,
+                        ex.Value);
+                    return null!;
+                }
+            }
+
+            // we run our SIGABRT checks first before handing over to user events
+            // because we delegate to user code, we need to protect anything that could happen in this event
+            if (options.BeforeSendInternal == null)
+                return evt;
+
+            try
+            {
+                var sentryEvent = evt.ToSentryEvent();
+                if (sentryEvent == null)
+                    return evt;
+
+                var result = options.BeforeSendInternal(sentryEvent, null!);
+                if (result == null)
+                    return null!;
+
+                // we only support a subset of mutated data to be passed back to the native SDK at this time
+                result.CopyToCocoaSentryEvent(evt);
+
+                // Note: Nullable result is allowed but delegate is generated incorrectly
+                // See https://github.com/xamarin/xamarin-macios/issues/15299#issuecomment-1201863294
+                return evt!;
+            }
+            catch (Exception ex)
+            {
+                options.LogError(ex, "Before Send Error");
+                return evt;
+            }
+        };
+
+        if (options.OnCrashedLastRun is { } onCrashedLastRun)
+        {
+            nativeOptions.OnCrashedLastRun = evt =>
+            {
+                // because we delegate to user code, we need to protect anything that could happen in this event
+                try
+                {
+                    var sentryEvent = evt.ToSentryEvent();
+                    if (sentryEvent != null)
+                    {
+                        onCrashedLastRun(sentryEvent);
+                    }
+                }
+                catch (Exception ex)
+                {
+                    options.LogError(ex, "Crashed Last Run Error");
+                }
+            };
+        }
 
         // These options are from Cocoa's SentryOptions
         nativeOptions.AttachScreenshot = options.Native.AttachScreenshot;
@@ -145,43 +206,6 @@ private static void InitSentryCocoaSdk(SentryOptions options)
         // nativeOptions.DefaultIntegrations
         // nativeOptions.EnableProfiling  (deprecated)
 
-        // When we have an unhandled managed exception, we send that to Sentry twice - once managed and once native.
-        // The managed exception is what a .NET developer would expect, and it is sent by the Sentry.NET SDK
-        // But we also get a native SIGABRT since it crashed the application, which is sent by the Sentry Cocoa SDK.
-        nativeOptions.BeforeSend = evt =>
-        {
-            // There should only be one exception on the event in this case
-            if (evt.Exceptions?.Length == 1)
-            {
-                // It will match the following characteristics
-                var ex = evt.Exceptions[0];
-
-                // Thankfully, sometimes we can see Xamarin's unhandled exception handler on the stack trace, so we can filter
-                // them out. Here is the function that calls abort(), which we will use as a filter:
-                // https://github.com/xamarin/xamarin-macios/blob/c55fbdfef95028ba03d0f7a35aebca03bd76f852/runtime/runtime.m#L1114-L1122
-                if (ex.Type == "SIGABRT" && ex.Value == "Signal 6, Code 0" &&
-                    ex.Stacktrace?.Frames.Any(f => f.Function == "xamarin_unhandled_exception_handler") is true)
-                {
-                    // Don't send it
-                    options.LogDebug("Discarded {0} error ({1}). Captured as  managed exception instead.", ex.Type, ex.Value);
-                    return null!;
-                }
-
-                // Similar workaround for NullReferenceExceptions. We don't have any easy way to know whether the
-                // exception is managed code (compiled to native) or original native code though.
-                // See: https://github.com/getsentry/sentry-dotnet/issues/3776
-                if (ex.Type == "EXC_BAD_ACCESS")
-                {
-                    // Don't send it
-                    options.LogDebug("Discarded {0} error ({1}). Captured as  managed exception instead.", ex.Type, ex.Value);
-                    return null!;
-                }
-            }
-
-            // Other event, send as normal
-            return evt;
-        };
-
         // Set hybrid SDK name
         SentryCocoaHybridSdk.SetSdkName("sentry.cocoa.dotnet");
 
diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs
index b580a89881..81abbe4ddd 100644
--- a/src/Sentry/SentryEvent.cs
+++ b/src/Sentry/SentryEvent.cs
@@ -286,11 +286,29 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
     /// </summary>
     public static SentryEvent FromJson(JsonElement json) => FromJson(json, null);
 
+    private static SentryLevel? SafeLevelFromJson(JsonElement json)
+    {
+        var levelString = json.GetPropertyOrNull("level")?.GetString();
+        if (levelString == null)
+            return null;
+
+        // Native SentryLevel.None does not exist in dotnet
+        return levelString.ToLowerInvariant() switch
+        {
+            "debug" => SentryLevel.Debug,
+            "info" => SentryLevel.Info,
+            "warning" => SentryLevel.Warning,
+            "fatal" => SentryLevel.Fatal,
+            "error" => SentryLevel.Error,
+            _ => null
+        };
+    }
+
     internal static SentryEvent FromJson(JsonElement json, Exception? exception)
     {
         var modules = json.GetPropertyOrNull("modules")?.GetStringDictionaryOrNull();
         var eventId = json.GetPropertyOrNull("event_id")?.Pipe(SentryId.FromJson) ?? SentryId.Empty;
-        var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
+        var timestamp = json.GetSafeDateTimeOffset("timestamp"); // Native sentryevents are serialized to epoch timestamps
         var message = json.GetPropertyOrNull("logentry")?.Pipe(SentryMessage.FromJson);
         var logger = json.GetPropertyOrNull("logger")?.GetString();
         var platform = json.GetPropertyOrNull("platform")?.GetString();
@@ -299,7 +317,7 @@ internal static SentryEvent FromJson(JsonElement json, Exception? exception)
         var distribution = json.GetPropertyOrNull("dist")?.GetString();
         var exceptionValues = json.GetPropertyOrNull("exception")?.GetPropertyOrNull("values")?.EnumerateArray().Select(SentryException.FromJson).ToList().Pipe(v => new SentryValues<SentryException>(v));
         var threadValues = json.GetPropertyOrNull("threads")?.GetPropertyOrNull("values")?.EnumerateArray().Select(SentryThread.FromJson).ToList().Pipe(v => new SentryValues<SentryThread>(v));
-        var level = json.GetPropertyOrNull("level")?.GetString()?.ParseEnum<SentryLevel>();
+
         var transaction = json.GetPropertyOrNull("transaction")?.GetString();
         var request = json.GetPropertyOrNull("request")?.Pipe(SentryRequest.FromJson);
         var contexts = json.GetPropertyOrNull("contexts")?.Pipe(SentryContexts.FromJson);
@@ -310,7 +328,7 @@ internal static SentryEvent FromJson(JsonElement json, Exception? exception)
         var breadcrumbs = json.GetPropertyOrNull("breadcrumbs")?.EnumerateArray().Select(Breadcrumb.FromJson).ToList();
         var extra = json.GetPropertyOrNull("extra")?.GetDictionaryOrNull();
         var tags = json.GetPropertyOrNull("tags")?.GetStringDictionaryOrNull();
-
+        var level = SafeLevelFromJson(json);
         var debugMeta = json.GetPropertyOrNull("debug_meta")?.Pipe(DebugMeta.FromJson);
 
         return new SentryEvent(exception, timestamp, eventId)
diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs
index 7c99988acf..69a87685a7 100644
--- a/src/Sentry/SentryOptions.cs
+++ b/src/Sentry/SentryOptions.cs
@@ -1075,6 +1075,14 @@ public StackTraceMode StackTraceMode
     /// </summary>
     public Func<bool>? CrashedLastRun { get; set; }
 
+#if IOS || MACCATALYST
+    // this event currently isn't being pushed from Android
+    /// <summary>
+    /// Delegate which is run with event information if the application crashed during last run.
+    /// </summary>
+    public Action<SentryEvent>? OnCrashedLastRun { get; set; }
+#endif
+
     /// <summary>
     /// <para>
     ///     Gets the <see cref="Instrumenter"/> used to create spans.
diff --git a/test/Sentry.Tests/Platforms/iOS/CocoaExtensionsTests.cs b/test/Sentry.Tests/Platforms/iOS/CocoaExtensionsTests.cs
new file mode 100644
index 0000000000..04e782e639
--- /dev/null
+++ b/test/Sentry.Tests/Platforms/iOS/CocoaExtensionsTests.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using Foundation;
+using Sentry.Cocoa.Extensions;
+using Xunit;
+
+namespace Sentry.Tests.Platforms.iOS;
+
+public class CocoaExtensionsTests
+{
+    [Fact]
+    public void CopyToCocoaSentryEvent_CopiesProperties()
+    {
+        var evt = new SentryEvent(new Exception("Test Exception"));
+
+        evt.Level = SentryLevel.Debug;
+        evt.ServerName = "test server name";
+        evt.Distribution = "test distribution";
+        evt.Logger = "test logger";
+        evt.Release = "test release";
+        evt.Environment = "test environment";
+        evt.TransactionName = "test transaction name";
+        evt.Message = new SentryMessage { Params = ["Test"] };
+        evt.SetTag("TestTagKey", "TestTagValue");
+        evt.AddBreadcrumb(new Breadcrumb("test breadcrumb", "test type"));
+        evt.SetExtra("TestExtraKey", "TestExtraValue");
+        evt.User = new SentryUser
+        {
+            Id = "user id",
+            Username = "test",
+            Email = "test@sentry.io",
+            IpAddress = "127.0.0.1"
+        };
+
+        var native = new CocoaSdk.SentryEvent();
+        evt.CopyToCocoaSentryEvent(native);
+
+        AssertEqual(evt, native);
+
+        // message - native does not copy this over to dotnet
+        native.Message.Should().NotBeNull("Message should not be null");
+        native.Message.Params.Should().NotBeNull("Message params should not be null");
+        native.Message.Params.First().Should().Be(evt.Message!.Params!.First().ToString());
+    }
+
+
+    [Fact]
+    public void ToSentryEvent_ConvertToManaged()
+    {
+        var native = new CocoaSdk.SentryEvent();
+
+        native.Timestamp = DateTimeOffset.UtcNow.ToNSDate();
+        native.Level = Sentry.CocoaSdk.SentryLevel.Debug;
+        native.ServerName = "native server name";
+        native.Dist = "native dist";
+        native.Logger = "native logger";
+        native.ReleaseName = "native release";
+        native.Environment = "native env";
+        native.Transaction = "native transaction";
+        native.Message = new SentryMessage { Params = ["Test"] }.ToCocoaSentryMessage();
+        native.Tags = new Dictionary<string, string> { { "TestTagKey", "TestTagValue" } }.ToNSDictionaryStrings();
+        native.Extra = new Dictionary<string, string> { { "TestExtraKey", "TestExtraValue" } }.ToNSDictionary();
+        native.Error = new NSError(new NSString("Test Error"), IntPtr.Zero);
+        native.Breadcrumbs =
+        [
+            new CocoaSdk.SentryBreadcrumb(CocoaSdk.SentryLevel.Debug, "category")
+        ];
+        native.User = new CocoaSdk.SentryUser
+        {
+            UserId = "user id",
+            Username = "test",
+            Email = "test@sentry.io",
+            IpAddress = "127.0.0.1"
+        };
+        var managed = native.ToSentryEvent();
+        AssertEqual(managed, native);
+    }
+
+    private static void AssertEqual(SentryEvent managed, CocoaSdk.SentryEvent native)
+    {
+        native.ServerName.Should().Be(managed.ServerName, "Server Name");
+        native.Dist.Should().Be(managed.Distribution, "Distribution");
+        native.Logger.Should().Be(managed.Logger, "Logger");
+        native.ReleaseName.Should().Be(managed.Release, "Release");
+        native.Environment.Should().Be(managed.Environment, "Environment");
+        native.Transaction.Should().Be(managed.TransactionName!, "Transaction");
+        native.Level!.ToString().Should().Be(managed.Level.ToString(), "Level");
+
+        native.Extra.Should().NotBeNull("No extras found");
+        native.Extra.Count.Should().Be(1, "Extras should have 1 item");
+        native.Extra!.Keys![0]!.Should().Be(new NSString(managed.Extra.Keys.First()), "Extras key should match");
+        native.Extra!.Values![0]!.Should().Be(NSObject.FromObject(managed.Extra.Values.First()), "Extra value should match");
+
+        // tags
+        native.Tags.Should().NotBeNull("No tags found");
+        native.Tags.Count.Should().Be(1, "Tags should have 1 item");
+        native.Tags!.Keys![0]!.Should().Be(new NSString(managed.Tags.Keys.First()));
+        native.Tags!.Values![0]!.Should().Be(new NSString(managed.Tags.Values.First()));
+
+        // breadcrumbs
+        native.Breadcrumbs.Should().NotBeNull("No breadcrumbs found");
+        var nb = native.Breadcrumbs!.First();
+        var mb = managed.Breadcrumbs!.First();
+        nb.Message.Should().Be(mb.Message, "Breadcrumb message");
+        nb.Type.Should().Be(mb.Type, "Breadcrumb type");
+
+        // user
+        native.User!.UserId.Should().Be(managed.User.Id, "UserId should match");
+        native.User.Email.Should().Be(managed.User.Email, "Email should match");
+        native.User.Username.Should().Be(managed.User.Username, "Username should match");
+        native.User.IpAddress.Should().Be(managed.User.IpAddress, "IpAddress should match");
+
+        // check contains because ios/android dotnet tend to move how this works from time to time
+        managed.Exception!.ToString().Contains(native.Error!.Domain!).Should().BeTrue("Domain message should be included in dotnet exception");
+    }
+}
